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:
committed by
GitHub
parent
f5b4a267c0
commit
d4a8c4d2e1
@@ -1,48 +0,0 @@
|
||||
# Terminal WebSocket Transport Protocol
|
||||
|
||||
## Goal
|
||||
Use a single persistent WebSocket for terminal input and output, while keeping the legacy SSE output route and HTTP input route as compatibility fallbacks.
|
||||
|
||||
## Scope
|
||||
- Primary full-duplex path: WebSocket (`/api/terminal/ws`)
|
||||
- Legacy output fallback: SSE (`/api/terminal/:sessionId/stream`)
|
||||
- HTTP input fallback remains: `POST /api/terminal/:sessionId/input`
|
||||
|
||||
## Framing
|
||||
- Text frame:
|
||||
- client -> server: terminal keystroke payload
|
||||
- server -> client: raw PTY output chunk
|
||||
- Binary frame: control envelope
|
||||
- Byte 0: tag (`0x01` = JSON control)
|
||||
- Bytes 1..N: UTF-8 JSON payload
|
||||
|
||||
## Control Messages
|
||||
- Bind active socket to terminal session:
|
||||
- client -> server: `{"t":"b","s":"<sessionId>","v":2}`
|
||||
- Keepalive ping:
|
||||
- client -> server: `{"t":"p","v":2}`
|
||||
- server -> client: `{"t":"po","v":2}`
|
||||
- Server control responses:
|
||||
- ready: `{"t":"ok","v":2}`
|
||||
- bind ok: `{"t":"bok","s":"<sessionId>","runtime":"node|bun","ptyBackend":"...","v":2}`
|
||||
- exit: `{"t":"x","s":"<sessionId>","exitCode":0,"signal":null}`
|
||||
- error: `{"t":"e","c":"<code>","f":true|false}`
|
||||
|
||||
## Multiplexing Model
|
||||
- Single shared socket per client runtime.
|
||||
- Socket has one mutable bound session.
|
||||
- Client sends a bind control when the active terminal changes.
|
||||
- Text frames always apply to the currently bound session.
|
||||
- PTY output is pushed back over the same socket as text frames.
|
||||
- Client keeps the socket primed so both stream subscription and input reuse the same transport.
|
||||
|
||||
## Security
|
||||
- UI auth session required when UI password is enabled.
|
||||
- Origin validation enforced for cookie-authenticated browser upgrades.
|
||||
- Invalid or malformed frames are rate-limited and may close the socket.
|
||||
|
||||
## Fallback Behavior
|
||||
- New clients prefer `capabilities.stream.ws` and reuse the same socket for input.
|
||||
- If stream WebSocket capability is unavailable, clients fall back to SSE output.
|
||||
- If terminal input cannot be sent over WebSocket, clients fall back to HTTP input.
|
||||
- The removed `/api/terminal/input-ws` path should fail with `404 Not Found`.
|
||||
@@ -163,9 +163,6 @@ function shouldSkipCompression(req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/terminal/') && pathname.endsWith('/stream')) {
|
||||
return true;
|
||||
}
|
||||
for (const prefix of SSE_PATH_PREFIXES) {
|
||||
if (pathname === prefix) {
|
||||
return true;
|
||||
|
||||
@@ -123,7 +123,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `resolveWslExecutablePath()`
|
||||
- `buildWslExecArgs(execArgs, distroOverride?)`
|
||||
- `isExecutable(filePath)`
|
||||
- `searchPathFor(binaryName)`
|
||||
- `searchPathFor(binaryName, searchPath?)`: resolves an executable from the supplied PATH value, defaulting to the process PATH.
|
||||
- `clearResolvedOpenCodeBinary()`
|
||||
|
||||
## Public exports (env-config.js)
|
||||
|
||||
@@ -87,14 +87,13 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
return isExecutable(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
const searchPathFor = (binaryName) => {
|
||||
const searchPathFor = (binaryName, searchPath = process.env.PATH || '') => {
|
||||
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = process.env.PATH || '';
|
||||
const parts = current.split(path.delimiter).filter(Boolean);
|
||||
const parts = searchPath.split(path.delimiter).filter(Boolean);
|
||||
const candidateNames = [];
|
||||
|
||||
if (process.platform === 'win32' && !path.extname(trimmed)) {
|
||||
|
||||
@@ -118,6 +118,19 @@ const createRuntime = (settings, options = {}) => {
|
||||
};
|
||||
|
||||
describe('OpenCode env runtime', () => {
|
||||
it('searches an explicit PATH without mutating the process environment', () => {
|
||||
const defaultDir = createTempDir('openchamber-default-path-');
|
||||
const explicitDir = createTempDir('openchamber-explicit-path-');
|
||||
const binary = path.join(explicitDir, process.platform === 'win32' ? 'custom-shell.exe' : 'custom-shell');
|
||||
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n');
|
||||
if (process.platform !== 'win32') fs.chmodSync(binary, 0o755);
|
||||
process.env.PATH = defaultDir;
|
||||
const { runtime } = createRuntime({});
|
||||
|
||||
expect(runtime.searchPathFor('custom-shell', explicitDir)).toBe(binary);
|
||||
expect(process.env.PATH).toBe(defaultDir);
|
||||
});
|
||||
|
||||
it('throws a specific error for a missing configured OpenCode binary in strict mode', async () => {
|
||||
const { runtime } = createRuntime({ opencodeBinary: '/missing/opencode' });
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
|
||||
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
||||
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
||||
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
|
||||
const HIDDEN_MODELS_MAX = 1024;
|
||||
const RECENT_EFFORTS_MAX_KEYS = 128;
|
||||
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
|
||||
@@ -547,6 +548,16 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
|
||||
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
|
||||
}
|
||||
if (typeof candidate.terminalShell === 'string') {
|
||||
const shell = candidate.terminalShell.trim().toLowerCase();
|
||||
if (TERMINAL_SHELL_VALUES.has(shell)) result.terminalShell = shell;
|
||||
}
|
||||
if (Array.isArray(candidate.terminalLoginShells)) {
|
||||
result.terminalLoginShells = [...new Set(candidate.terminalLoginShells
|
||||
.filter((shell) => typeof shell === 'string')
|
||||
.map((shell) => shell.trim().toLowerCase())
|
||||
.filter((shell) => TERMINAL_SHELL_VALUES.has(shell)))];
|
||||
}
|
||||
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
|
||||
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
|
||||
}
|
||||
|
||||
@@ -78,6 +78,19 @@ describe('settings helpers', () => {
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
|
||||
});
|
||||
|
||||
it('sanitizes the persisted terminal shell', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalShell: ' ZSH ' })).toEqual({ terminalShell: 'zsh' });
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'auto' })).toEqual({ terminalShell: 'auto' });
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalShell: '/bin/zsh' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'zsh -c whoami' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [' ZSH ', 'bash', 'zsh', '/bin/fish', 42] })).toEqual({
|
||||
terminalLoginShells: ['zsh', 'bash'],
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [] })).toEqual({ terminalLoginShells: [] });
|
||||
});
|
||||
|
||||
it('accepts desktopLanAccessEnabled as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
|
||||
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
|
||||
const PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version'];
|
||||
const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location'];
|
||||
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
@@ -1235,19 +1236,19 @@ export const createPreviewProxyRuntime = ({
|
||||
const match = pathname.match(/^\/api\/preview\/proxy\/([a-f0-9]{16,64})(?:\/|$)/i);
|
||||
const id = match?.[1] || '';
|
||||
if (!id) {
|
||||
return { ok: false, status: 404, error: 'Preview target not found' };
|
||||
return { ok: false, status: 404, code: 'missing', error: 'Preview target not found' };
|
||||
}
|
||||
|
||||
const entry = targets.get(id);
|
||||
if (!entry || entry.expiresAt <= now()) {
|
||||
targets.delete(id);
|
||||
return { ok: false, status: 404, error: 'Preview target expired' };
|
||||
return { ok: false, status: 404, code: 'expired', error: 'Preview target expired' };
|
||||
}
|
||||
|
||||
const cookies = parseCookieHeader(req.headers?.cookie);
|
||||
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
|
||||
if (!token || token !== entry.token) {
|
||||
return { ok: false, status: 403, error: 'Preview token missing' };
|
||||
return { ok: false, status: 403, code: 'invalid-token', error: 'Preview token missing' };
|
||||
}
|
||||
|
||||
return { ok: true, id, entry, parsed };
|
||||
@@ -1451,6 +1452,9 @@ export const createPreviewProxyRuntime = ({
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
},
|
||||
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
|
||||
// This header is reserved for failures produced before proxying. Do
|
||||
// not let an upstream application response impersonate that signal.
|
||||
res.removeHeader?.(PREVIEW_TARGET_ERROR_HEADER);
|
||||
applyPreviewPassthroughResponseHeaders(proxyRes, res);
|
||||
// Per-response nonce lets the injected bridge run under the dev
|
||||
// server's CSP without dropping its script restrictions wholesale.
|
||||
@@ -1551,6 +1555,7 @@ export const createPreviewProxyRuntime = ({
|
||||
app.use('/api/preview/proxy', (req, res, next) => {
|
||||
const resolved = resolveTargetFromRequest(req);
|
||||
if (!resolved.ok) {
|
||||
res.setHeader(PREVIEW_TARGET_ERROR_HEADER, resolved.code);
|
||||
return res.status(resolved.status).json({ error: resolved.error });
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -5,12 +5,77 @@ import {
|
||||
applyPreviewPassthroughResponseHeaders,
|
||||
classifyPreviewNavigation,
|
||||
classifyPreviewResourceError,
|
||||
createPreviewProxyRuntime,
|
||||
normalizeProxyTargetUrl,
|
||||
PREVIEW_TARGET_ERROR_HEADER,
|
||||
rewritePreviewBody,
|
||||
rewritePreviewCspHeader,
|
||||
rewritePreviewRedirectLocation,
|
||||
} from './proxy-runtime.js';
|
||||
|
||||
const createResponse = () => {
|
||||
const headers = new Map();
|
||||
return {
|
||||
body: null,
|
||||
statusCode: 200,
|
||||
headers,
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
removeHeader(name) {
|
||||
headers.delete(name.toLowerCase());
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body) {
|
||||
this.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createAttachedPreviewRuntime = () => {
|
||||
let proxyOptions;
|
||||
const postRoutes = new Map();
|
||||
const useRoutes = new Map();
|
||||
let randomByte = 0;
|
||||
const runtime = createPreviewProxyRuntime({
|
||||
crypto: {
|
||||
randomBytes(size) {
|
||||
randomByte += 1;
|
||||
return Buffer.alloc(size, randomByte);
|
||||
},
|
||||
},
|
||||
URL,
|
||||
createProxyMiddleware(options) {
|
||||
proxyOptions = options;
|
||||
const middleware = () => {};
|
||||
middleware.upgrade = () => {};
|
||||
return middleware;
|
||||
},
|
||||
responseInterceptor: (handler) => handler,
|
||||
});
|
||||
const app = {
|
||||
post(path, ...handlers) {
|
||||
postRoutes.set(path, handlers);
|
||||
},
|
||||
use(path, ...handlers) {
|
||||
useRoutes.set(path, handlers);
|
||||
},
|
||||
};
|
||||
runtime.attach(app, {
|
||||
server: { on() {} },
|
||||
express: { json: () => (_req, _res, next) => next() },
|
||||
uiAuthController: null,
|
||||
isRequestOriginAllowed: async () => true,
|
||||
rejectWebSocketUpgrade() {},
|
||||
});
|
||||
|
||||
return { postRoutes, proxyOptions: () => proxyOptions, useRoutes };
|
||||
};
|
||||
|
||||
const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||
bodyText,
|
||||
kind,
|
||||
@@ -18,6 +83,77 @@ const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||
targetOrigin: 'http://127.0.0.1:3000',
|
||||
});
|
||||
|
||||
describe('preview target failure signaling', () => {
|
||||
it('marks missing and expired targets instead of relying on the HTTP status alone', () => {
|
||||
const { useRoutes } = createAttachedPreviewRuntime();
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
for (const [originalUrl, code, error] of [
|
||||
['/api/preview/proxy/', 'missing', 'Preview target not found'],
|
||||
[`/api/preview/proxy/${'a'.repeat(32)}/`, 'expired', 'Preview target expired'],
|
||||
]) {
|
||||
const response = createResponse();
|
||||
guard({ originalUrl, headers: {} }, response, () => {});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe(code);
|
||||
expect(response.body).toEqual({ error });
|
||||
}
|
||||
});
|
||||
|
||||
it('marks invalid target tokens and accepts a registered target token', async () => {
|
||||
const { postRoutes, useRoutes } = createAttachedPreviewRuntime();
|
||||
const [, registerTarget] = postRoutes.get('/api/preview/targets');
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
const registrationResponse = createResponse();
|
||||
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
|
||||
|
||||
const { id, previewToken } = registrationResponse.body;
|
||||
const invalidResponse = createResponse();
|
||||
guard({ originalUrl: `/api/preview/proxy/${id}/`, headers: {} }, invalidResponse, () => {});
|
||||
expect(invalidResponse.statusCode).toBe(403);
|
||||
expect(invalidResponse.headers.get(PREVIEW_TARGET_ERROR_HEADER)).toBe('invalid-token');
|
||||
|
||||
const validResponse = createResponse();
|
||||
let continued = false;
|
||||
guard({
|
||||
originalUrl: `/api/preview/proxy/${id}/?oc_preview_token=${previewToken}`,
|
||||
headers: {},
|
||||
}, validResponse, () => {
|
||||
continued = true;
|
||||
});
|
||||
expect(continued).toBe(true);
|
||||
expect(validResponse.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the reserved target-error marker from upstream responses', async () => {
|
||||
const { postRoutes, proxyOptions, useRoutes } = createAttachedPreviewRuntime();
|
||||
const [, registerTarget] = postRoutes.get('/api/preview/targets');
|
||||
const registrationResponse = createResponse();
|
||||
await registerTarget({ body: { url: 'http://127.0.0.1:4323/' }, secure: false }, registrationResponse);
|
||||
const { id, previewToken } = registrationResponse.body;
|
||||
const request = {
|
||||
originalUrl: `/api/preview/proxy/${id}/missing?oc_preview_token=${previewToken}`,
|
||||
headers: {},
|
||||
};
|
||||
const response = createResponse();
|
||||
response.setHeader(PREVIEW_TARGET_ERROR_HEADER, 'expired');
|
||||
|
||||
await proxyOptions().on.proxyRes(
|
||||
Buffer.from('{"error":"upstream missing"}'),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
request,
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.headers.has(PREVIEW_TARGET_ERROR_HEADER)).toBe(false);
|
||||
const [guard] = useRoutes.get('/api/preview/proxy');
|
||||
let continued = false;
|
||||
guard(request, createResponse(), () => {
|
||||
continued = true;
|
||||
});
|
||||
expect(continued).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview Inertia header passthrough', () => {
|
||||
it('forwards Inertia request headers to the preview target', () => {
|
||||
const forwarded = new Map();
|
||||
|
||||
@@ -7,8 +7,7 @@ const isAllowedSsePath = (pathname) => {
|
||||
return pathname === '/api/event'
|
||||
|| pathname === '/api/global/event'
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname);
|
||||
|| pathname === '/api/notifications/stream';
|
||||
};
|
||||
|
||||
const isAllowedWebSocketPath = (pathname) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,76 +1,45 @@
|
||||
# Terminal Module Documentation
|
||||
# Terminal Subsystem
|
||||
|
||||
## Purpose
|
||||
This module provides WebSocket transport utilities for terminal input and output in the web server runtime, including message normalization, control frame parsing, rate limiting, pathname resolution, and short-lived output replay buffering for terminal WebSocket connections.
|
||||
## Ownership
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/terminal/`: Terminal module directory.
|
||||
- `index.js`: Stable module entrypoint that re-exports protocol helpers and replay-buffer helpers.
|
||||
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
|
||||
- `terminal-ws-protocol.js`: Single-file module containing terminal WebSocket protocol utilities.
|
||||
- `output-replay-buffer.js`: Helper module for buffering recent terminal output so late subscribers can receive startup prompt data.
|
||||
- `packages/web/server/lib/terminal/terminal-ws-protocol.test.js`: Test file for protocol utilities.
|
||||
- `packages/web/server/lib/terminal/output-replay-buffer.test.js`: Test file for replay buffer helpers.
|
||||
`runtime.js` owns terminal identity, PTY processes, status, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families and resolves the persisted shell ID without accepting command strings or arguments. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
|
||||
|
||||
Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
|
||||
## Protocol
|
||||
|
||||
## Public exports
|
||||
`/api/terminal/ws` is the only terminal data transport. It uses v3 binary JSON control frames and is opened through `openRuntimeWebSocket`, preserving direct, Electron proxy, URL-token authentication, and private-relay routing.
|
||||
|
||||
### Constants
|
||||
- `TERMINAL_WS_PATH`: Primary WebSocket endpoint path (`/api/terminal/ws`).
|
||||
- `TERMINAL_WS_CONTROL_TAG_JSON`: Control frame tag byte (`0x01`) indicating JSON payload.
|
||||
- `TERMINAL_WS_MAX_PAYLOAD_BYTES`: Maximum inbound WebSocket payload size (64KB).
|
||||
- `TERMINAL_OUTPUT_REPLAY_MAX_BYTES`: Maximum buffered terminal output retained for replay (64KB).
|
||||
- `attach` registers a connection for one terminal. One socket may attach to many terminals.
|
||||
- Every attach and reconnect begins with an authoritative `snapshot` containing bounded history and the current sequence.
|
||||
- `output`, `exit`, and `restarted` carry monotonically increasing per-terminal sequences. Output carries raw live bytes plus replay-safe bytes with terminal query exchanges removed.
|
||||
- Attach registers before capturing the snapshot, buffers concurrent events, drops events represented by the snapshot sequence, then enters live delivery.
|
||||
- `write` always includes the terminal ID; sockets never have mutable single-terminal binding state.
|
||||
- `detach` removes only that attachment.
|
||||
- Creation carries the active UI appearance. The PTY sets `COLORFGBG` and answers OSC 10, OSC 11, and Mode 2031 queries immediately, including queries emitted before a WebSocket attachment exists. Subscribed TUIs receive a Mode 2031 notification when the appearance changes.
|
||||
|
||||
### Request Parsing
|
||||
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
|
||||
- `isTerminalWsPathname(pathname)`: Returns whether a pathname matches a supported terminal WebSocket route.
|
||||
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
|
||||
|
||||
### Message Normalization
|
||||
- `normalizeTerminalWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
|
||||
- `normalizeTerminalWsMessageToText(rawData)`: Normalizes data to UTF-8 text string.
|
||||
## PTY Lifecycle
|
||||
|
||||
### Control Frame Handling
|
||||
- `readTerminalWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid or malformed.
|
||||
- `createTerminalWsControlFrame(payload)`: Creates a control frame with JSON payload and prepends the control tag byte.
|
||||
- IDs are client-provided or generated with `randomUUID()`.
|
||||
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
|
||||
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
|
||||
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
|
||||
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
|
||||
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
|
||||
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
|
||||
- Exited sessions remain attachable until explicit close or idle cleanup.
|
||||
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID.
|
||||
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
|
||||
|
||||
### Replay Buffer Helpers
|
||||
- `createTerminalOutputReplayBuffer()`: Creates mutable state for recent terminal output replay.
|
||||
- `appendTerminalOutputReplayChunk(bufferState, data, maxBytes?)`: Appends a chunk, trimming older buffered data to stay within the configured byte budget.
|
||||
- `listTerminalOutputReplayChunksSince(bufferState, lastSeenId)`: Returns buffered chunks newer than the provided replay cursor.
|
||||
- `getLatestTerminalOutputReplayChunkId(bufferState)`: Returns the latest chunk id in the replay buffer, or `0` when empty.
|
||||
## Security And Relay
|
||||
|
||||
### Rate Limiting
|
||||
- `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
|
||||
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded the configured threshold.
|
||||
The WebSocket path must remain in both `isUrlAuthWebSocketPath` and relay `ALLOWED_WS_PATHS`. The client must use `getRuntimeUrlResolver().websocket()` and `openRuntimeWebSocket`; direct local URLs or raw browser WebSockets break relay and URL-token authentication.
|
||||
|
||||
## Usage in web server
|
||||
The terminal helpers are used by `packages/web/server/index.js` for:
|
||||
- WebSocket endpoint path definition and matching
|
||||
- Message normalization for terminal input payloads
|
||||
- Control frame parsing for session binding, keepalive, and exit signaling
|
||||
- Rate limiting for session rebind operations
|
||||
- Request pathname parsing for WebSocket routing
|
||||
- Replaying startup output such as shell prompts when the client binds after the PTY already emitted data
|
||||
## Verification
|
||||
|
||||
The web server combines these utilities with `bun-pty` or `node-pty` to drive full-duplex PTY sessions.
|
||||
Run:
|
||||
|
||||
## Notes for contributors
|
||||
- Keep control frames backward-compatible when possible; use explicit `v` values for protocol changes.
|
||||
- Always normalize incoming WebSocket messages before processing them.
|
||||
- Keep replay buffering small and memory-only; it exists to cover startup races, not to implement persistent scrollback.
|
||||
- Add tests for new control frame types, websocket path changes, malformed payload handling, and replay trimming semantics.
|
||||
- Keep HTTP input and SSE output fallbacks functional unless the rollout explicitly removes them.
|
||||
|
||||
## Verification notes
|
||||
### Manual verification
|
||||
1. Start the web server and create a terminal session via `/api/terminal/create`.
|
||||
2. Wait briefly before binding the client to ensure the shell emits its prompt first.
|
||||
3. Connect to `/api/terminal/ws` WebSocket and bind to the session.
|
||||
4. Verify the startup prompt and early shell output are replayed before interactive input begins.
|
||||
5. Verify `/api/terminal/input-ws` is rejected with `404 Not Found` and `/api/terminal/:sessionId/stream` still works as a fallback path.
|
||||
|
||||
### Automated verification
|
||||
- Run `bun test packages/web/server/lib/terminal/terminal-ws-protocol.test.js`
|
||||
- Run `bun test packages/web/server/lib/terminal/output-replay-buffer.test.js`
|
||||
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|
||||
```sh
|
||||
bun test packages/web/server/lib/terminal/runtime.test.js packages/web/server/lib/terminal/terminal-ws-protocol.test.js
|
||||
bun test packages/web/server/lib/ui-auth/ui-auth.test.js packages/web/server/lib/relay/cross-compat.test.js
|
||||
```
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const isCsiFinalByte = (code) => code >= 0x40 && code <= 0x7e;
|
||||
const shouldStripCsi = (body, finalByte) =>
|
||||
finalByte === 'n'
|
||||
|| (finalByte === 'R' && /^[0-9;?]*$/.test(body))
|
||||
|| (finalByte === 'c' && /^[>0-9;?]*$/.test(body))
|
||||
|| ((finalByte === 'p' || finalByte === 'y') && /^\?2031(?:;[0-9]+)?\$$/.test(body))
|
||||
|| ((finalByte === 'h' || finalByte === 'l') && body === '?2031');
|
||||
const shouldStripOsc = (content) => /^(10|11|12);(?:\?|rgb:)/.test(content);
|
||||
const stripTerminator = (value) => {
|
||||
if (value.endsWith('\u001b\\')) return value.slice(0, -2);
|
||||
return value.endsWith('\u0007') || value.endsWith('\u009c') ? value.slice(0, -1) : value;
|
||||
};
|
||||
const findStringEnd = (input, start) => {
|
||||
for (let index = start; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code === 0x07 || code === 0x9c) return index + 1;
|
||||
if (code === 0x1b && input.charCodeAt(index + 1) === 0x5c) return index + 2;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const findEscapeEnd = (input, start) => {
|
||||
let cursor = start;
|
||||
while (cursor < input.length && input.charCodeAt(cursor) >= 0x20 && input.charCodeAt(cursor) <= 0x2f) cursor += 1;
|
||||
if (cursor >= input.length) return null;
|
||||
return input.charCodeAt(cursor) >= 0x30 && input.charCodeAt(cursor) <= 0x7e ? cursor + 1 : start + 1;
|
||||
};
|
||||
|
||||
export const sanitizeTerminalHistoryChunk = (pending, data) => {
|
||||
const input = `${pending}${data}`;
|
||||
let visible = '';
|
||||
let index = 0;
|
||||
while (index < input.length) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code === 0x1b) {
|
||||
const next = input.charCodeAt(index + 1);
|
||||
if (Number.isNaN(next)) return { visible, pending: input.slice(index) };
|
||||
if (next === 0x5b) {
|
||||
let cursor = index + 2;
|
||||
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
|
||||
if (cursor >= input.length) return { visible, pending: input.slice(index) };
|
||||
const sequence = input.slice(index, cursor + 1);
|
||||
if (!shouldStripCsi(input.slice(index + 2, cursor), input[cursor])) visible += sequence;
|
||||
index = cursor + 1;
|
||||
continue;
|
||||
}
|
||||
if (next === 0x5d || next === 0x50 || next === 0x5e || next === 0x5f) {
|
||||
const end = findStringEnd(input, index + 2);
|
||||
if (end === null) return { visible, pending: input.slice(index) };
|
||||
const sequence = input.slice(index, end);
|
||||
const content = stripTerminator(input.slice(index + 2, end));
|
||||
if (next !== 0x5d || !shouldStripOsc(content)) visible += sequence;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
const end = findEscapeEnd(input, index + 1);
|
||||
if (end === null) return { visible, pending: input.slice(index) };
|
||||
visible += input.slice(index, end);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x9b) {
|
||||
let cursor = index + 1;
|
||||
while (cursor < input.length && !isCsiFinalByte(input.charCodeAt(cursor))) cursor += 1;
|
||||
if (cursor >= input.length) return { visible, pending: input.slice(index) };
|
||||
const sequence = input.slice(index, cursor + 1);
|
||||
if (!shouldStripCsi(input.slice(index + 1, cursor), input[cursor])) visible += sequence;
|
||||
index = cursor + 1;
|
||||
continue;
|
||||
}
|
||||
if (code === 0x9d || code === 0x90 || code === 0x9e || code === 0x9f) {
|
||||
const end = findStringEnd(input, index + 1);
|
||||
if (end === null) return { visible, pending: input.slice(index) };
|
||||
const sequence = input.slice(index, end);
|
||||
const content = stripTerminator(input.slice(index + 1, end));
|
||||
if (code !== 0x9d || !shouldStripOsc(content)) visible += sequence;
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
visible += input[index];
|
||||
index += 1;
|
||||
}
|
||||
return { visible, pending: '' };
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeTerminalHistoryChunk } from './history.js';
|
||||
|
||||
describe('terminal replay history', () => {
|
||||
it('removes device and color query exchanges while preserving display controls', () => {
|
||||
const input = `before\u001b[6n\u001b[12;40R\u001b[>0c\u001b[?2031h\u001b[?2031$p\u001b[?2031;1$y\u001b]10;?\u0007\u001b[31mred\u001b[0mafter`;
|
||||
expect(sanitizeTerminalHistoryChunk('', input)).toEqual({ visible: 'before\u001b[31mred\u001b[0mafter', pending: '' });
|
||||
});
|
||||
|
||||
it('carries incomplete control sequences across PTY chunks', () => {
|
||||
const first = sanitizeTerminalHistoryChunk('', 'text\u001b]11;');
|
||||
expect(first).toEqual({ visible: 'text', pending: '\u001b]11;' });
|
||||
expect(sanitizeTerminalHistoryChunk(first.pending, '?\u001b\\next')).toEqual({ visible: 'next', pending: '' });
|
||||
});
|
||||
|
||||
it('preserves ordinary OSC titles and split UTF-16 text', () => {
|
||||
expect(sanitizeTerminalHistoryChunk('', '\u001b]0;title\u0007ok')).toEqual({ visible: '\u001b]0;title\u0007ok', pending: '' });
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
export const TERMINAL_OUTPUT_REPLAY_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const trimTerminalOutputChunkToMaxBytes = (data, maxBytes) => {
|
||||
if (typeof data !== 'string' || data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bytes = Buffer.byteLength(data, 'utf8');
|
||||
if (bytes <= maxBytes) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const kept = [];
|
||||
let trimmedBytes = 0;
|
||||
const characters = Array.from(data);
|
||||
for (let index = characters.length - 1; index >= 0; index -= 1) {
|
||||
const character = characters[index];
|
||||
const characterBytes = Buffer.byteLength(character, 'utf8');
|
||||
if (trimmedBytes + characterBytes > maxBytes) {
|
||||
break;
|
||||
}
|
||||
kept.push(character);
|
||||
trimmedBytes += characterBytes;
|
||||
}
|
||||
|
||||
return kept.reverse().join('');
|
||||
};
|
||||
|
||||
export const createTerminalOutputReplayBuffer = () => ({
|
||||
chunks: [],
|
||||
totalBytes: 0,
|
||||
nextId: 1,
|
||||
});
|
||||
|
||||
export const appendTerminalOutputReplayChunk = (bufferState, data, maxBytes = TERMINAL_OUTPUT_REPLAY_MAX_BYTES) => {
|
||||
if (!bufferState || typeof bufferState !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedData = trimTerminalOutputChunkToMaxBytes(data, maxBytes);
|
||||
if (!normalizedData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.byteLength(normalizedData, 'utf8');
|
||||
const chunk = {
|
||||
id: bufferState.nextId,
|
||||
data: normalizedData,
|
||||
bytes,
|
||||
};
|
||||
|
||||
bufferState.nextId += 1;
|
||||
bufferState.chunks.push(chunk);
|
||||
bufferState.totalBytes += bytes;
|
||||
|
||||
while (bufferState.totalBytes > maxBytes && bufferState.chunks.length > 1) {
|
||||
const removedChunk = bufferState.chunks.shift();
|
||||
bufferState.totalBytes -= removedChunk?.bytes ?? 0;
|
||||
}
|
||||
|
||||
return chunk;
|
||||
};
|
||||
|
||||
export const listTerminalOutputReplayChunksSince = (bufferState, lastSeenId = 0) => {
|
||||
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return bufferState.chunks.filter((chunk) => chunk.id > lastSeenId);
|
||||
};
|
||||
|
||||
export const getLatestTerminalOutputReplayChunkId = (bufferState) => {
|
||||
if (!bufferState || typeof bufferState !== 'object' || !Array.isArray(bufferState.chunks) || bufferState.chunks.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return bufferState.chunks[bufferState.chunks.length - 1]?.id ?? 0;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
||||
appendTerminalOutputReplayChunk,
|
||||
createTerminalOutputReplayBuffer,
|
||||
getLatestTerminalOutputReplayChunkId,
|
||||
listTerminalOutputReplayChunksSince,
|
||||
} from './output-replay-buffer.js';
|
||||
|
||||
describe('terminal output replay buffer', () => {
|
||||
it('starts empty', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
expect(bufferState).toEqual({ chunks: [], totalBytes: 0, nextId: 1 });
|
||||
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(0);
|
||||
});
|
||||
|
||||
it('appends chunks with incrementing ids', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const first = appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
|
||||
const second = appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
|
||||
|
||||
expect(first).toEqual({ id: 1, data: 'prompt> ', bytes: 8 });
|
||||
expect(second).toEqual({ id: 2, data: 'ls\r\n', bytes: 4 });
|
||||
expect(getLatestTerminalOutputReplayChunkId(bufferState)).toBe(2);
|
||||
});
|
||||
|
||||
it('lists chunks after a replay cursor', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
appendTerminalOutputReplayChunk(bufferState, 'prompt> ');
|
||||
appendTerminalOutputReplayChunk(bufferState, 'ls\r\n');
|
||||
appendTerminalOutputReplayChunk(bufferState, 'file.txt\r\n');
|
||||
|
||||
expect(listTerminalOutputReplayChunksSince(bufferState, 1).map((chunk) => chunk.data)).toEqual([
|
||||
'ls\r\n',
|
||||
'file.txt\r\n',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trims old chunks beyond max bytes', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
appendTerminalOutputReplayChunk(bufferState, '1234', 8);
|
||||
appendTerminalOutputReplayChunk(bufferState, '5678', 8);
|
||||
appendTerminalOutputReplayChunk(bufferState, '90', 8);
|
||||
|
||||
expect(bufferState.chunks.map((chunk) => chunk.data)).toEqual(['5678', '90']);
|
||||
expect(bufferState.totalBytes).toBe(6);
|
||||
});
|
||||
|
||||
it('trims oversized single chunks to the configured max bytes', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const chunk = appendTerminalOutputReplayChunk(bufferState, 'abcdefghij', 4);
|
||||
|
||||
expect(chunk?.data).toBe('ghij');
|
||||
expect(chunk?.bytes).toBe(4);
|
||||
expect(bufferState.totalBytes).toBe(4);
|
||||
});
|
||||
|
||||
it('does not split multibyte characters when trimming oversized chunks', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const chunk = appendTerminalOutputReplayChunk(bufferState, '🙂x', 2);
|
||||
|
||||
expect(chunk?.data).toBe('x');
|
||||
expect(chunk?.bytes).toBe(1);
|
||||
expect(bufferState.totalBytes).toBe(1);
|
||||
});
|
||||
|
||||
it('uses the default max bytes when not provided', () => {
|
||||
const bufferState = createTerminalOutputReplayBuffer();
|
||||
const chunk = appendTerminalOutputReplayChunk(bufferState, 'ok');
|
||||
|
||||
expect(chunk?.bytes).toBe(2);
|
||||
expect(TERMINAL_OUTPUT_REPLAY_MAX_BYTES).toBe(64 * 1024);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import express from 'express';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { createTerminalRuntime } from './runtime.js';
|
||||
import { createTerminalWsControlFrame, readTerminalWsControlFrame } from './terminal-ws-protocol.js';
|
||||
|
||||
function createResponse() {
|
||||
return {
|
||||
@@ -47,6 +51,53 @@ function createRuntime(server, overrides = {}) {
|
||||
}
|
||||
|
||||
describe('terminal runtime', () => {
|
||||
const createHarness = (overrides = {}) => {
|
||||
const routes = { get: new Map(), post: new Map(), delete: new Map() };
|
||||
const processes = [];
|
||||
const app = {
|
||||
post(route, handler) { routes.post.set(route, handler); },
|
||||
get(route, handler) { routes.get.set(route, handler); },
|
||||
delete(route, handler) { routes.delete.set(route, handler); },
|
||||
};
|
||||
const loadPtyProvider = async () => ({
|
||||
backend: 'fake-pty',
|
||||
spawn: (shell, args, options) => {
|
||||
const dataHandlers = new Set();
|
||||
const exitHandlers = new Set();
|
||||
const process = {
|
||||
pid: 123 + processes.length,
|
||||
shell,
|
||||
args,
|
||||
options,
|
||||
writes: [],
|
||||
resizes: [],
|
||||
killed: false,
|
||||
kills: [],
|
||||
write(data) { this.writes.push(data); },
|
||||
resize(cols, rows) { this.resizes.push([cols, rows]); },
|
||||
kill(signal) { this.killed = true; this.kills.push(signal ?? 'SIGTERM'); },
|
||||
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
|
||||
onExit(handler) { exitHandlers.add(handler); return { dispose: () => exitHandlers.delete(handler) }; },
|
||||
emitData(data) { for (const handler of dataHandlers) handler(data); },
|
||||
emitExit(exitCode = 0, signal = 0) { for (const handler of exitHandlers) handler({ exitCode, signal }); },
|
||||
};
|
||||
processes.push(process);
|
||||
return process;
|
||||
},
|
||||
});
|
||||
const server = new EventEmitter();
|
||||
const runtime = createRuntime(server, {
|
||||
app,
|
||||
loadPtyProvider,
|
||||
terminalTerminationGraceMs: 10,
|
||||
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
|
||||
searchPathFor: () => '/bin/sh',
|
||||
isExecutable: () => true,
|
||||
...overrides,
|
||||
});
|
||||
return { routes, processes, runtime };
|
||||
};
|
||||
|
||||
it('rejects regular files as terminal working directories', async () => {
|
||||
const postRoutes = new Map();
|
||||
const app = {
|
||||
@@ -93,4 +144,386 @@ describe('terminal runtime', () => {
|
||||
|
||||
expect(server.listenerCount('upgrade')).toBe(0);
|
||||
});
|
||||
|
||||
it('creates client-identified sessions and forwards bounded resize operations', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
|
||||
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
|
||||
expect(harness.processes[0].options.cwd).toBe('/repo');
|
||||
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
|
||||
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
|
||||
harness.processes[0].emitData('\u001b[?2031h\u001b]10;?\u0007\u001b]11;?\u0007');
|
||||
expect(harness.processes[0].writes).toEqual(['\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\', '\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
|
||||
|
||||
const appearance = createResponse();
|
||||
harness.routes.post.get('/api/terminal/:sessionId/appearance')({ params: { sessionId: 'term-1' }, body: { themeMode: 'dark' } }, appearance);
|
||||
expect(appearance.body).toEqual({ success: true });
|
||||
expect(harness.processes[0].writes.at(-1)).toBe('\u001b[?997;1n');
|
||||
|
||||
const resize = createResponse();
|
||||
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 200, rows: 60 } }, resize);
|
||||
expect(resize.statusCode).toBe(200);
|
||||
expect(harness.processes[0].resizes).toEqual([[200, 60]]);
|
||||
|
||||
const invalid = createResponse();
|
||||
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 1001, rows: 60 } }, invalid);
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('lists available shells and uses the selected shell for create and restart', async () => {
|
||||
const executables = new Set(['/bin/zsh', '/bin/bash', '/bin/sh']);
|
||||
const harness = createHarness({
|
||||
fs: {
|
||||
promises: {
|
||||
stat: async () => ({ isDirectory: () => true }),
|
||||
readFile: async () => '/bin/zsh\n/bin/bash\n/bin/false\n',
|
||||
},
|
||||
},
|
||||
searchPathFor: (name) => executables.has(`/bin/${name}`) ? `/bin/${name}` : null,
|
||||
isExecutable: (candidate) => executables.has(candidate),
|
||||
});
|
||||
try {
|
||||
const listed = createResponse();
|
||||
await harness.routes.get.get('/api/terminal/shells')({}, listed);
|
||||
expect(listed.body).toEqual(expect.arrayContaining([
|
||||
{ id: 'auto', name: 'Auto', supportsLogin: true },
|
||||
{ id: 'zsh', name: 'zsh', supportsLogin: true },
|
||||
{ id: 'bash', name: 'bash', supportsLogin: true },
|
||||
{ id: 'sh', name: 'sh', supportsLogin: false },
|
||||
]));
|
||||
|
||||
const created = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-shell', cwd: '/repo', shell: 'zsh', loginShell: true } }, created);
|
||||
expect(created.statusCode).toBe(200);
|
||||
expect(harness.processes[0].shell).toBe('/bin/zsh');
|
||||
expect(harness.processes[0].args).toEqual(['-l']);
|
||||
|
||||
const restarted = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-shell' }, body: { shell: 'bash', loginShell: true } }, restarted);
|
||||
expect(restarted.statusCode).toBe(200);
|
||||
expect(harness.processes[1].shell).toBe('/bin/bash');
|
||||
expect(harness.processes[1].args).toEqual(['-l']);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects invalid and unavailable explicit shells', async () => {
|
||||
const harness = createHarness({
|
||||
fs: {
|
||||
promises: {
|
||||
stat: async () => ({ isDirectory: () => true }),
|
||||
readFile: async () => '/bin/sh\n',
|
||||
},
|
||||
},
|
||||
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
|
||||
isExecutable: (candidate) => candidate === '/bin/sh',
|
||||
});
|
||||
try {
|
||||
for (const [shell, error] of [
|
||||
['zsh -c whoami', 'Invalid terminal shell'],
|
||||
['fish', 'Terminal shell "fish" is not available'],
|
||||
]) {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell } }, response);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error });
|
||||
}
|
||||
expect(harness.processes).toHaveLength(0);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects invalid and unsupported login modes', async () => {
|
||||
const harness = createHarness({
|
||||
fs: {
|
||||
promises: {
|
||||
stat: async () => ({ isDirectory: () => true }),
|
||||
readFile: async () => '/bin/sh\n',
|
||||
},
|
||||
},
|
||||
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
|
||||
isExecutable: (candidate) => candidate === '/bin/sh',
|
||||
});
|
||||
try {
|
||||
for (const [loginShell, error] of [
|
||||
['true', 'Invalid terminal login mode'],
|
||||
[true, 'Terminal shell "sh" does not support login mode'],
|
||||
]) {
|
||||
const response = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { cwd: '/repo', shell: 'sh', loginShell } }, response);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({ error });
|
||||
}
|
||||
expect(harness.processes).toHaveLength(0);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('preserves the running process when a replacement shell is unavailable', async () => {
|
||||
const harness = createHarness({
|
||||
fs: {
|
||||
promises: {
|
||||
stat: async () => ({ isDirectory: () => true }),
|
||||
readFile: async () => '/bin/sh\n',
|
||||
},
|
||||
},
|
||||
searchPathFor: (name) => name === 'sh' ? '/bin/sh' : null,
|
||||
isExecutable: (candidate) => candidate === '/bin/sh',
|
||||
});
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', shell: 'sh' } }, createResponse());
|
||||
const restarted = createResponse();
|
||||
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { shell: 'fish' } }, restarted);
|
||||
|
||||
expect(restarted.statusCode).toBe(400);
|
||||
expect(restarted.body.error).toBe('Terminal shell "fish" is not available');
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
expect(harness.processes[0].killed).toBe(false);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('deduplicates concurrent creates and rejects cross-directory id reuse', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const first = createResponse();
|
||||
const second = createResponse();
|
||||
await Promise.all([
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, first),
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, second),
|
||||
]);
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
expect(first.body.sessionId).toBe('term-shared');
|
||||
expect(second.body.sessionId).toBe('term-shared');
|
||||
|
||||
const conflicting = createResponse();
|
||||
await create({ body: { sessionId: 'term-shared', cwd: '/other' } }, conflicting);
|
||||
expect(conflicting.statusCode).toBe(400);
|
||||
expect(conflicting.body.error).toBe('Terminal session belongs to a different working directory');
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects concurrent creates with conflicting shell preferences', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const first = createResponse();
|
||||
const conflicting = createResponse();
|
||||
await Promise.all([
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto' } }, first),
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'zsh' } }, conflicting),
|
||||
]);
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(conflicting.statusCode).toBe(400);
|
||||
expect(conflicting.body.error).toBe('Terminal session is already being created with a different shell');
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('rejects concurrent creates with conflicting login modes', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const first = createResponse();
|
||||
const conflicting = createResponse();
|
||||
await Promise.all([
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: false } }, first),
|
||||
create({ body: { sessionId: 'term-shared', cwd: '/repo', shell: 'auto', loginShell: true } }, conflicting),
|
||||
]);
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(conflicting.statusCode).toBe(400);
|
||||
expect(conflicting.body.error).toBe('Terminal session is already being created with a different login mode');
|
||||
expect(harness.processes).toHaveLength(1);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('restarts atomically with the same identity and closes the previous process', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
|
||||
const restarted = createResponse();
|
||||
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'term-1' }, body: { cwd: '/other', cols: 90, rows: 30 } }, restarted);
|
||||
expect(restarted.body).toEqual({ sessionId: 'term-1', cols: 90, rows: 30, status: 'running' });
|
||||
expect(harness.processes).toHaveLength(2);
|
||||
expect(harness.processes[0].killed).toBe(true);
|
||||
expect(harness.processes[1].options.cwd).toBe('/other');
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('serializes concurrent restarts without orphaning replacement processes', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const create = harness.routes.post.get('/api/terminal/create');
|
||||
const restart = harness.routes.post.get('/api/terminal/:sessionId/restart');
|
||||
await create({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
|
||||
const first = createResponse();
|
||||
const second = createResponse();
|
||||
|
||||
await Promise.all([
|
||||
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/first' } }, first),
|
||||
restart({ params: { sessionId: 'term-1' }, body: { cwd: '/second' } }, second),
|
||||
]);
|
||||
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(harness.processes).toHaveLength(3);
|
||||
expect(harness.processes[0].killed).toBe(true);
|
||||
expect(harness.processes[1].killed).toBe(true);
|
||||
expect(harness.processes[2].killed).toBe(false);
|
||||
expect(harness.processes[2].options.cwd).toBe('/second');
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('retains exited sessions until explicit close', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
|
||||
harness.processes[0].emitData('last output');
|
||||
harness.processes[0].emitExit(7, 0);
|
||||
const resize = createResponse();
|
||||
harness.routes.post.get('/api/terminal/:sessionId/resize')({ params: { sessionId: 'term-1' }, body: { cols: 80, rows: 24 } }, resize);
|
||||
expect(resize.statusCode).toBe(200);
|
||||
const closed = createResponse();
|
||||
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, closed);
|
||||
expect(closed.body).toEqual({ success: true });
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('escalates close to SIGKILL when a running process ignores SIGTERM', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo' } }, createResponse());
|
||||
await harness.routes.delete.get('/api/terminal/:sessionId')({ params: { sessionId: 'term-1' } }, createResponse());
|
||||
expect(harness.processes[0].kills).toEqual(['SIGTERM', 'SIGKILL']);
|
||||
} finally { await harness.runtime.shutdown(); }
|
||||
});
|
||||
|
||||
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const server = http.createServer(app);
|
||||
const processes = [];
|
||||
const loadPtyProvider = async () => ({
|
||||
backend: 'fake-pty',
|
||||
spawn: () => {
|
||||
const data = new Set();
|
||||
const exits = new Set();
|
||||
const process = {
|
||||
pid: 99123,
|
||||
killed: false,
|
||||
writes: [],
|
||||
write(value) { this.writes.push(value); }, resize() {}, kill() { this.killed = true; },
|
||||
onData(handler) { data.add(handler); return { dispose: () => data.delete(handler) }; },
|
||||
onExit(handler) { exits.add(handler); return { dispose: () => exits.delete(handler) }; },
|
||||
emitData(value) { for (const handler of data) handler(value); },
|
||||
emitExit(exitCode) { for (const handler of exits) handler({ exitCode, signal: 0 }); },
|
||||
};
|
||||
processes.push(process);
|
||||
return process;
|
||||
},
|
||||
});
|
||||
const runtime = createRuntime(server, {
|
||||
app, loadPtyProvider,
|
||||
terminalTerminationGraceMs: 10,
|
||||
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
|
||||
searchPathFor: () => '/bin/sh', isExecutable: () => true,
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
const base = `http://127.0.0.1:${address.port}`;
|
||||
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
|
||||
const sockets = [];
|
||||
|
||||
const open = async () => {
|
||||
const socket = new WebSocket(socketUrl);
|
||||
sockets.push(socket);
|
||||
const messages = [];
|
||||
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
|
||||
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
|
||||
const next = async (type, sessionId) => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
|
||||
if (index >= 0) return messages.splice(index, 1)[0];
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${type}`);
|
||||
};
|
||||
await next('hello');
|
||||
return { socket, next, messages };
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: 'term-live', cwd: '/repo', cols: 80, rows: 24 }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const secondCreated = await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: 'term-second', cwd: '/other', cols: 80, rows: 24 }),
|
||||
});
|
||||
expect(secondCreated.status).toBe(200);
|
||||
|
||||
const first = await open();
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
|
||||
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
|
||||
expect(await first.next('snapshot', 'term-second')).toMatchObject({ s: 'term-second', q: 0, history: '', status: 'running' });
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo ok\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-second', d: 'pwd\r' }));
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'write', v: 3, s: 'term-live', d: 'echo next\r' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(processes[0].writes).toEqual(['echo ok\r', 'echo next\r']);
|
||||
expect(processes[1].writes).toEqual(['pwd\r']);
|
||||
|
||||
processes[1].emitData('/other\r\n');
|
||||
expect(await first.next('output', 'term-second')).toMatchObject({ s: 'term-second', q: 1, d: '/other\r\n' });
|
||||
first.socket.send(createTerminalWsControlFrame({ t: 'detach', v: 3, s: 'term-second' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
processes[1].emitData('detached\r\n');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(first.messages.some((message) => message?.t === 'output' && message.s === 'term-second')).toBe(false);
|
||||
|
||||
processes[0].emitData('ok\r\n');
|
||||
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 1, d: 'ok\r\n' });
|
||||
processes[0].emitData('\u001b[6n');
|
||||
expect(await first.next('output', 'term-live')).toMatchObject({ s: 'term-live', q: 2, d: '\u001b[6n', r: '' });
|
||||
const secondClosed = await fetch(`${base}/api/terminal/term-second`, { method: 'DELETE' });
|
||||
expect(secondClosed.status).toBe(200);
|
||||
first.socket.close();
|
||||
|
||||
const second = await open();
|
||||
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
|
||||
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
|
||||
processes[0].emitExit(7);
|
||||
expect(await second.next('exit')).toMatchObject({ s: 'term-live', q: 3, exitCode: 7 });
|
||||
|
||||
const closed = await fetch(`${base}/api/terminal/term-live`, { method: 'DELETE' });
|
||||
expect(closed.status).toBe(200);
|
||||
expect(await second.next('error')).toMatchObject({ s: 'term-live', code: 'CLOSED', fatal: true });
|
||||
|
||||
await fetch(`${base}/api/terminal/create`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: 'term-kill', cwd: '/repo' }),
|
||||
});
|
||||
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-kill' }));
|
||||
await second.next('snapshot');
|
||||
const killed = await fetch(`${base}/api/terminal/force-kill`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cwd: '/repo' }),
|
||||
});
|
||||
expect(await killed.json()).toEqual({ success: true, killedCount: 1, killedSessionIds: ['term-kill'] });
|
||||
expect(await second.next('error')).toMatchObject({ s: 'term-kill', code: 'KILLED', fatal: true });
|
||||
expect(processes[2].killed).toBe(true);
|
||||
} finally {
|
||||
for (const socket of sockets) socket.terminate();
|
||||
await runtime.shutdown();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const TERMINAL_SHELL_IDS = ['bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu'];
|
||||
const TERMINAL_SHELL_ID_SET = new Set(TERMINAL_SHELL_IDS);
|
||||
|
||||
export const normalizeTerminalShell = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'auto' || TERMINAL_SHELL_ID_SET.has(normalized) ? normalized : null;
|
||||
};
|
||||
|
||||
const shellIdFromPath = (value) => {
|
||||
const filename = String(value || '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() || '';
|
||||
const id = filename.endsWith('.exe') ? filename.slice(0, -4) : filename;
|
||||
return TERMINAL_SHELL_ID_SET.has(id) ? id : null;
|
||||
};
|
||||
|
||||
const SHELL_LABELS = { pwsh: 'PowerShell', powershell: 'Windows PowerShell', cmd: 'Command Prompt' };
|
||||
const shellLabel = (id) => SHELL_LABELS[id] ?? id;
|
||||
|
||||
export const getTerminalShellLoginArgs = (executable, platform = process.platform) => {
|
||||
const id = shellIdFromPath(executable);
|
||||
if (id === 'bash' || id === 'zsh' || id === 'ksh') return ['-l'];
|
||||
if (id === 'fish' || id === 'nu') return ['--login'];
|
||||
if (id === 'pwsh' && platform !== 'win32') return ['-Login'];
|
||||
return null;
|
||||
};
|
||||
|
||||
export const createTerminalShellResolver = ({ fs, path, searchPathFor, isExecutable, buildAugmentedPath = () => env.PATH || '', platform = process.platform, env = process.env }) => {
|
||||
const resolveExecutable = (candidate) => {
|
||||
if (!candidate) return null;
|
||||
const value = String(candidate);
|
||||
const found = value.includes('/') || value.includes('\\') ? value : searchPathFor(value, buildAugmentedPath());
|
||||
if (found && isExecutable(found)) return found;
|
||||
return isExecutable(value) ? value : null;
|
||||
};
|
||||
|
||||
const defaultCandidates = () => platform === 'win32'
|
||||
? [
|
||||
env.OPENCHAMBER_TERMINAL_SHELL,
|
||||
env.SHELL,
|
||||
env.ComSpec,
|
||||
path.join(env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
'pwsh.exe',
|
||||
'powershell.exe',
|
||||
'cmd.exe',
|
||||
]
|
||||
: [env.OPENCHAMBER_TERMINAL_SHELL, env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh', 'zsh', 'bash', 'sh'];
|
||||
|
||||
const resolveCandidates = (candidates) => {
|
||||
const seen = new Set();
|
||||
return candidates
|
||||
.map(resolveExecutable)
|
||||
.filter((candidate) => candidate && !seen.has(candidate) && seen.add(candidate));
|
||||
};
|
||||
|
||||
const list = async () => {
|
||||
let configuredShells = [];
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
const contents = await fs.promises.readFile('/etc/shells', 'utf8');
|
||||
configuredShells = contents
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'));
|
||||
} catch {
|
||||
// PATH and platform defaults below remain authoritative fallbacks.
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = platform === 'win32'
|
||||
? [...defaultCandidates(), ...TERMINAL_SHELL_IDS]
|
||||
: [env.OPENCHAMBER_TERMINAL_SHELL, env.SHELL, ...configuredShells, ...TERMINAL_SHELL_IDS, '/bin/zsh', '/bin/bash', '/bin/sh'];
|
||||
const autoExecutable = resolveCandidates(defaultCandidates())[0] ?? null;
|
||||
const byId = new Map([
|
||||
['auto', {
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
executable: autoExecutable,
|
||||
supportsLogin: Boolean(autoExecutable && getTerminalShellLoginArgs(autoExecutable, platform)),
|
||||
}],
|
||||
]);
|
||||
for (const executable of resolveCandidates(candidates)) {
|
||||
const id = shellIdFromPath(executable);
|
||||
if (id && !byId.has(id)) {
|
||||
byId.set(id, { id, name: shellLabel(id), executable, supportsLogin: Boolean(getTerminalShellLoginArgs(executable, platform)) });
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
};
|
||||
|
||||
const resolve = async (preference) => {
|
||||
const normalized = normalizeTerminalShell(preference ?? 'auto');
|
||||
if (!normalized) throw new Error('Invalid terminal shell');
|
||||
if (normalized === 'auto') return { id: 'auto', executables: resolveCandidates(defaultCandidates()) };
|
||||
const selected = (await list()).find((shell) => shell.id === normalized);
|
||||
if (!selected) throw new Error(`Terminal shell "${normalized}" is not available`);
|
||||
return { id: selected.id, executables: [selected.executable] };
|
||||
};
|
||||
|
||||
return { list, resolve };
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTerminalShellResolver, getTerminalShellLoginArgs } from './shells.js';
|
||||
|
||||
const createResolver = ({ platform = 'linux', env = {}, augmentedPath = '/augmented/bin', executables = [] } = {}) => {
|
||||
const available = new Set(executables);
|
||||
const path = {
|
||||
delimiter: platform === 'win32' ? ';' : ':',
|
||||
extname: (value) => /\.[^./\\]+$/.exec(value)?.[0] ?? '',
|
||||
join: (...parts) => parts.join(platform === 'win32' ? '\\' : '/'),
|
||||
};
|
||||
const searches = [];
|
||||
return {
|
||||
searches,
|
||||
resolver: createTerminalShellResolver({
|
||||
fs: { promises: { readFile: async () => '' } },
|
||||
path,
|
||||
platform,
|
||||
env,
|
||||
buildAugmentedPath: () => augmentedPath,
|
||||
searchPathFor: (name, searchPath) => {
|
||||
searches.push([name, searchPath]);
|
||||
const suffixes = platform === 'win32' ? ['', '.exe'] : [''];
|
||||
for (const suffix of suffixes) {
|
||||
const match = [...available].find((candidate) => candidate.toLowerCase().endsWith(`${platform === 'win32' ? '\\' : '/'}${name}${suffix}`.toLowerCase()));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
isExecutable: (candidate) => available.has(candidate),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('terminal shell resolver', () => {
|
||||
it('discovers shells from the augmented PTY PATH', async () => {
|
||||
const { resolver, searches } = createResolver({ executables: ['/augmented/bin/fish'] });
|
||||
|
||||
await expect(resolver.list()).resolves.toContainEqual({ id: 'fish', name: 'fish', executable: '/augmented/bin/fish', supportsLogin: true });
|
||||
expect(searches).toContainEqual(['fish', '/augmented/bin']);
|
||||
});
|
||||
|
||||
it('discovers supported PATH-installed shells on Windows', async () => {
|
||||
const { resolver } = createResolver({
|
||||
platform: 'win32',
|
||||
augmentedPath: 'C:\\Tools',
|
||||
executables: ['C:\\Tools\\bash.exe', 'C:\\Tools\\nu.exe'],
|
||||
});
|
||||
|
||||
await expect(resolver.list()).resolves.toEqual(expect.arrayContaining([
|
||||
{ id: 'bash', name: 'bash', executable: 'C:\\Tools\\bash.exe', supportsLogin: true },
|
||||
{ id: 'nu', name: 'nu', executable: 'C:\\Tools\\nu.exe', supportsLogin: true },
|
||||
]));
|
||||
});
|
||||
|
||||
it('uses environment overrides before platform defaults for auto', async () => {
|
||||
const { resolver } = createResolver({
|
||||
env: { OPENCHAMBER_TERMINAL_SHELL: '/custom/zsh', SHELL: '/bin/bash' },
|
||||
executables: ['/custom/zsh', '/bin/bash'],
|
||||
});
|
||||
|
||||
await expect(resolver.resolve('auto')).resolves.toEqual({ id: 'auto', executables: ['/custom/zsh', '/bin/bash'] });
|
||||
});
|
||||
|
||||
it('uses only known platform-safe login arguments', () => {
|
||||
expect(getTerminalShellLoginArgs('/bin/bash', 'linux')).toEqual(['-l']);
|
||||
expect(getTerminalShellLoginArgs('/opt/homebrew/bin/fish', 'darwin')).toEqual(['--login']);
|
||||
expect(getTerminalShellLoginArgs('/usr/bin/nu', 'linux')).toEqual(['--login']);
|
||||
expect(getTerminalShellLoginArgs('/usr/bin/pwsh', 'linux')).toEqual(['-Login']);
|
||||
expect(getTerminalShellLoginArgs('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'win32')).toBeNull();
|
||||
expect(getTerminalShellLoginArgs('/bin/dash', 'linux')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const MODE_SET = '\u001b[?2031h';
|
||||
const MODE_RESET = '\u001b[?2031l';
|
||||
const CAPABILITY_QUERY = '\u001b[?2031$p';
|
||||
const MODE_QUERIES = ['\u001b[?996n', '\u001b[?997n'];
|
||||
const OSC_QUERIES = [10, 11].flatMap((code) => [
|
||||
{ sequence: `\u001b]${code};?\u0007`, code },
|
||||
{ sequence: `\u001b]${code};?\u001b\\`, code },
|
||||
]);
|
||||
const CONTROL_SEQUENCES = [MODE_SET, MODE_RESET, CAPABILITY_QUERY, ...MODE_QUERIES, ...OSC_QUERIES.map(({ sequence }) => sequence)];
|
||||
|
||||
const parseColor = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
||||
if (hex) {
|
||||
const expanded = hex.length === 3 ? [...hex].map((part) => part + part).join('') : hex;
|
||||
return [0, 2, 4].map((offset) => Number.parseInt(expanded.slice(offset, offset + 2), 16));
|
||||
}
|
||||
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
||||
return rgb ? rgb.slice(1, 4).map((part) => Math.min(255, Number(part))) : null;
|
||||
};
|
||||
|
||||
const colorReport = (code, color) => {
|
||||
const rgb = parseColor(color);
|
||||
if (!rgb) return null;
|
||||
const channels = rgb.map((channel) => channel.toString(16).padStart(2, '0').repeat(2));
|
||||
return `\u001b]${code};rgb:${channels.join('/')}\u001b\\`;
|
||||
};
|
||||
|
||||
export const terminalThemeModeReport = (themeMode) => `\u001b[?997;${themeMode === 'light' ? 2 : 1}n`;
|
||||
|
||||
export const consumeTerminalThemeQueries = (pending, data, appearance) => {
|
||||
if (!pending && !data.includes('\u001b')) return { pending: '', responses: [], modeEnabled: appearance.modeEnabled === true };
|
||||
const input = `${pending}${data}`;
|
||||
const responses = [];
|
||||
let modeEnabled = appearance.modeEnabled === true;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
if (input.startsWith(MODE_SET, index)) {
|
||||
modeEnabled = true;
|
||||
index += MODE_SET.length - 1;
|
||||
continue;
|
||||
}
|
||||
if (input.startsWith(MODE_RESET, index)) {
|
||||
modeEnabled = false;
|
||||
index += MODE_RESET.length - 1;
|
||||
continue;
|
||||
}
|
||||
if (input.startsWith(CAPABILITY_QUERY, index)) {
|
||||
responses.push(`\u001b[?2031;${modeEnabled ? 1 : 2}$y`);
|
||||
index += CAPABILITY_QUERY.length - 1;
|
||||
continue;
|
||||
}
|
||||
const modeQuery = MODE_QUERIES.find((query) => input.startsWith(query, index));
|
||||
if (modeQuery) {
|
||||
responses.push(terminalThemeModeReport(appearance.themeMode));
|
||||
index += modeQuery.length - 1;
|
||||
continue;
|
||||
}
|
||||
const oscQuery = OSC_QUERIES.find(({ sequence }) => input.startsWith(sequence, index));
|
||||
if (oscQuery) {
|
||||
const response = colorReport(oscQuery.code, oscQuery.code === 10 ? appearance.foreground : appearance.background);
|
||||
if (response) responses.push(response);
|
||||
index += oscQuery.sequence.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
let nextPending = '';
|
||||
const maxLength = Math.max(...CONTROL_SEQUENCES.map((sequence) => sequence.length));
|
||||
for (let length = 1; length < Math.min(input.length + 1, maxLength); length += 1) {
|
||||
const suffix = input.slice(-length);
|
||||
if (CONTROL_SEQUENCES.some((sequence) => sequence.length > length && sequence.startsWith(suffix))) nextPending = suffix;
|
||||
}
|
||||
return { pending: nextPending, responses, modeEnabled };
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { consumeTerminalThemeQueries } from './theme-response.js';
|
||||
|
||||
const lightAppearance = {
|
||||
themeMode: 'light',
|
||||
foreground: '#1b1b1b',
|
||||
background: '#faf8f0',
|
||||
modeEnabled: false,
|
||||
};
|
||||
|
||||
describe('terminal theme responses', () => {
|
||||
test('answers the complete OpenTUI startup handshake', () => {
|
||||
const result = consumeTerminalThemeQueries(
|
||||
'',
|
||||
'\u001b[?2031h\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[?2031$p',
|
||||
lightAppearance,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
pending: '',
|
||||
modeEnabled: true,
|
||||
responses: [
|
||||
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
|
||||
'\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\',
|
||||
'\u001b[?2031;1$y',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('handles a query split across PTY output chunks without duplicating it', () => {
|
||||
const first = consumeTerminalThemeQueries('', '\u001b]11;', { ...lightAppearance, themeMode: 'dark' });
|
||||
const second = consumeTerminalThemeQueries(first.pending, '?\u001b\\', { ...lightAppearance, themeMode: 'dark', modeEnabled: first.modeEnabled });
|
||||
const third = consumeTerminalThemeQueries(second.pending, 'x', { ...lightAppearance, themeMode: 'dark', modeEnabled: second.modeEnabled });
|
||||
expect(second.responses).toEqual(['\u001b]11;rgb:fafa/f8f8/f0f0\u001b\\']);
|
||||
expect(second.pending).toBe('');
|
||||
expect(third.responses).toEqual([]);
|
||||
});
|
||||
|
||||
test('answers every repeated query in wire order', () => {
|
||||
const result = consumeTerminalThemeQueries('', '\u001b[?996n\u001b[?996n\u001b]10;?\u0007\u001b]10;?\u0007', lightAppearance);
|
||||
expect(result.responses).toEqual([
|
||||
'\u001b[?997;2n',
|
||||
'\u001b[?997;2n',
|
||||
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
|
||||
'\u001b]10;rgb:1b1b/1b1b/1b1b\u001b\\',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -301,7 +301,6 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
|| pathname === '/api/fs/serve'
|
||||
|| pathname.startsWith('/api/fs/serve/')
|
||||
|| pathname.startsWith('/api/preview/proxy/')
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
||||
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user