diff --git a/AGENTS.md b/AGENTS.md index a00b4514..962de8e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,10 @@ GitHub authentication, OAuth device flow, Octokit client factory, and repository OpenCode server integration utilities including config management, provider authentication, and UI authentication. - Module docs: `packages/web/server/lib/opencode/DOCUMENTATION.md` +##### terminal +WebSocket protocol utilities for terminal input handling including message normalization, control frame parsing, and rate limiting. +- Module docs: `packages/web/server/lib/terminal/DOCUMENTATION.md` + ## Build / dev commands (verified) All scripts are in `package.json`. - Validate: `bun run type-check`, `bun run lint` diff --git a/packages/web/server/index.js b/packages/web/server/index.js index ca7984dd..62094791 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -20,7 +20,7 @@ import { parseRequestPathname, pruneRebindTimestamps, readTerminalInputWsControlFrame, -} from './lib/terminal-input-ws-protocol.js'; +} from './lib/terminal/index.js'; import webPush from 'web-push'; const __filename = fileURLToPath(import.meta.url); diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md new file mode 100644 index 00000000..809f8361 --- /dev/null +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -0,0 +1,114 @@ +# Terminal Module Documentation + +## Purpose +This module provides WebSocket protocol utilities for terminal input handling in the web server runtime, including message normalization, control frame parsing, rate limiting, and pathname resolution for terminal WebSocket connections. + +## Entrypoints and structure +- `packages/web/server/lib/terminal/`: Terminal module directory. + - `index.js`: Stable module entrypoint that re-exports protocol helpers/constants. + - `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities. +- `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities. + +Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`. + +## Public exports + +### Constants +- `TERMINAL_INPUT_WS_PATH`: WebSocket endpoint path (`/api/terminal/input-ws`). +- `TERMINAL_INPUT_WS_CONTROL_TAG_JSON`: Control frame tag byte (0x01) indicating JSON payload. +- `TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES`: Maximum payload size (64KB). + +### Request Parsing +- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs. + +### Message Normalization +- `normalizeTerminalInputWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer. +- `normalizeTerminalInputWsMessageToText(rawData)`: Normalizes data to UTF-8 text string. Passes through strings directly, converts binary data to text. + +### Control Frame Handling +- `readTerminalInputWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid/malformed. Validates control tag prefix and JSON structure. +- `createTerminalInputWsControlFrame(payload)`: Creates a control frame with JSON payload. Prepends control tag byte. + +### 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 rate limit threshold. + +## Response contracts + +### Control Frame +Control frames use binary encoding: +- First byte: `TERMINAL_INPUT_WS_CONTROL_TAG_JSON` (0x01) +- Remaining bytes: UTF-8 encoded JSON object +- Parsed result: Object or null on parse failure + +### Normalized Buffer +Input types are normalized to Buffer: +- `Buffer`: Returned as-is +- `Uint8Array`/`ArrayBuffer`: Converted to Buffer +- `String`: Converted to UTF-8 Buffer +- `Array`: Concatenated to single Buffer + +### Rate Limiting +Rate limiting uses timestamp arrays: +- `pruneRebindTimestamps`: Returns filtered array of active timestamps +- `isRebindRateLimited`: Returns boolean indicating if limit is reached + +## Usage in web server + +The terminal protocol utilities are used by `packages/web/server/index.js` for: +- WebSocket endpoint path definition (`TERMINAL_INPUT_WS_PATH`) +- Message normalization for input handling +- Control frame parsing for session binding +- Rate limiting for session rebind operations +- Request pathname parsing for WebSocket routing + +The web server uses these utilities in combination with `bun-pty` or `node-pty` for PTY session management. + +## Notes for contributors + +### Adding New Control Frame Types +1. Define new control tag constants (e.g., `TERMINAL_INPUT_WS_CONTROL_TAG_CUSTOM = 0x02`) +2. Update `readTerminalInputWsControlFrame` to handle new tag type +3. Update `createTerminalInputWsControlFrame` or create new frame creation function +4. Add corresponding tests in `terminal-input-ws-protocol.test.js` + +### Message Normalization +- Always normalize incoming WebSocket messages before processing +- Use `normalizeTerminalInputWsMessageToBuffer` for binary data +- Use `normalizeTerminalInputWsMessageToText` for text data (terminal escape sequences) +- Normalize chunked messages from WebSocket fragmentation handling + +### Rate Limiting +- Rate limiting is time-window based: tracks timestamps within a rolling window +- Use `pruneRebindTimestamps` to clean up stale timestamps before rate limit checks +- Configure `maxPerWindow` based on operational requirements (prevent abuse) + +### Error Handling +- `readTerminalInputWsControlFrame` returns null for invalid/malformed frames +- `parseRequestPathname` returns empty string for invalid URLs +- Callers should handle null/empty returns gracefully + +### Testing +- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes +- Test edge cases: empty payloads, malformed JSON, chunked messages, rate limit boundaries +- Verify control frame roundtrip: create → read → validate payload equality +- Test pathname parsing with relative URLs, absolute URLs, and invalid inputs + +## Verification notes + +### Manual verification +1. Start web server and create terminal session via `/api/terminal/create` +2. Connect to `/api/terminal/input-ws` WebSocket +3. Send control frames with valid/invalid payloads to verify parsing +4. Test message normalization with various data types +5. Verify rate limiting by issuing rapid rebind requests + +### Automated verification +- Run test file: `bun test packages/web/server/lib/terminal/input-ws-protocol.test.js` +- Protocol tests should pass covering: + - WebSocket path constant + - Control frame encoding/decoding + - Payload validation + - Message normalization (all data types) + - Pathname parsing + - Rate limiting logic diff --git a/packages/web/server/lib/terminal/index.js b/packages/web/server/lib/terminal/index.js new file mode 100644 index 00000000..9051c6ae --- /dev/null +++ b/packages/web/server/lib/terminal/index.js @@ -0,0 +1,12 @@ +export { + TERMINAL_INPUT_WS_PATH, + TERMINAL_INPUT_WS_CONTROL_TAG_JSON, + TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES, + parseRequestPathname, + normalizeTerminalInputWsMessageToBuffer, + normalizeTerminalInputWsMessageToText, + readTerminalInputWsControlFrame, + createTerminalInputWsControlFrame, + pruneRebindTimestamps, + isRebindRateLimited, +} from './input-ws-protocol.js'; diff --git a/packages/web/server/lib/terminal-input-ws-protocol.js b/packages/web/server/lib/terminal/input-ws-protocol.js similarity index 100% rename from packages/web/server/lib/terminal-input-ws-protocol.js rename to packages/web/server/lib/terminal/input-ws-protocol.js diff --git a/packages/web/server/lib/terminal-input-ws-protocol.test.js b/packages/web/server/lib/terminal/input-ws-protocol.test.js similarity index 99% rename from packages/web/server/lib/terminal-input-ws-protocol.test.js rename to packages/web/server/lib/terminal/input-ws-protocol.test.js index 3a3d4ef6..b9844ce4 100644 --- a/packages/web/server/lib/terminal-input-ws-protocol.test.js +++ b/packages/web/server/lib/terminal/input-ws-protocol.test.js @@ -10,7 +10,7 @@ import { parseRequestPathname, pruneRebindTimestamps, readTerminalInputWsControlFrame, -} from './terminal-input-ws-protocol.js'; +} from './input-ws-protocol.js'; describe('terminal input websocket protocol', () => { it('uses fixed websocket path', () => {