diff --git a/components/execd/PTY.md b/components/execd/PTY.md new file mode 100644 index 00000000..2ed0d6c6 --- /dev/null +++ b/components/execd/PTY.md @@ -0,0 +1,41 @@ +# Interactive PTY sessions + +Use this when you need a **long-lived Bash** driven over **WebSocket**: PTY mode behaves like a real terminal (colors, `stty`, resize); **pipe mode** (`pty=0`) splits stdout/stderr without a TTY. **Unix/macOS/Linux only** — not supported on Windows. + +## Typical usage + +1. **Create a session** (shell starts on the first WebSocket, not here): + + ```bash + curl -s -X POST http://127.0.0.1:44772/pty \ + -H 'Content-Type: application/json' \ + -d '{"cwd":"/tmp"}' + # → { "session_id": "" } + ``` + +2. **Open WebSocket** — default is PTY mode: + + ``` + ws://127.0.0.1:44772/pty//ws + ``` + + | Query | Use | + |-------|-----| + | `pty=0` | Pipe mode instead of PTY | + | `since=` | After reconnect, replay from byte offset (use `output_offset` from `GET /pty/:id`) | + +3. **Traffic** — after a JSON `connected` frame, the server sends **binary** chunks: first byte is the channel (`0x01` stdout, `0x02` stderr in pipe mode only, `0x03` replay with an 8-byte offset header). Send **stdin** as binary: `0x00` + raw bytes. For **resize** / **signals** / **ping**, send **JSON text** frames, e.g. `{"type":"resize","cols":120,"rows":40}`, `{"type":"signal","signal":"SIGINT"}`, `{"type":"ping"}`. + +4. **One WebSocket per session** — a second connection gets **409** until the first closes. + +5. **End** — when Bash exits, you get a JSON `exit` frame with `exit_code` and the socket closes. Use **`DELETE /pty/:id`** to tear down the session from the server side. + +## Modes + +- **PTY (default)** — ANSI and TTY-aware tools work as usual. +- **Pipe** — `?pty=0`; stderr is separate binary frames. Good when you do not need a TTY. + +## Notes + +- Output is also buffered for **replay**; reconnect with `since=` to catch up. +- In PTY streams, **shell echo** may appear before your command’s real output, so avoid matching only on text that also appears in the typed line. diff --git a/components/execd/README.md b/components/execd/README.md index 6cc3d43c..f929ecd6 100644 --- a/components/execd/README.md +++ b/components/execd/README.md @@ -31,6 +31,7 @@ English | [中文](README_zh.md) - **Command execution**: Synchronous and background shell commands - **File operations**: Full filesystem CRUD with chunked upload/download - **Monitoring**: Real-time host metrics (CPU, memory, uptime) +- **Interactive PTY**: Long-lived Bash over WebSocket (TTY or pipe mode, replay on reconnect) — see [PTY.md](./PTY.md) ## Core Features diff --git a/components/execd/README_zh.md b/components/execd/README_zh.md index 26e012dd..7aa70191 100644 --- a/components/execd/README_zh.md +++ b/components/execd/README_zh.md @@ -29,6 +29,7 @@ - **命令执行**:同步执行和异步执行 shell 命令 - **文件操作**:完整的文件系统 CRUD,支持分块上传/下载 - **监控**:实时系统指标(CPU、内存、运行时间) +- **交互式 PTY**:通过 WebSocket 长连接 Bash(TTY 或管道模式、支持按偏移回放)— 详见 [PTY.md](./PTY.md) ## 核心特性 diff --git a/components/execd/pkg/runtime/pty_session_test.go b/components/execd/pkg/runtime/pty_session_test.go index f7604ddc..52ad1f8a 100644 --- a/components/execd/pkg/runtime/pty_session_test.go +++ b/components/execd/pkg/runtime/pty_session_test.go @@ -119,13 +119,13 @@ func TestPTYSession_ANSISequences(t *testing.T) { _, err := s.WriteStdin([]byte("printf $'\\033[1;32mGREEN\\033[0m\\n'\n")) require.NoError(t, err) - // Wait for "GREEN" to appear in the replay buffer. - require.True(t, replayContains(t, s, "GREEN", 5*time.Second), - "expected 'GREEN' in replay buffer") + // Wait for ESC from printf output. Do not match on "GREEN" alone: the echoed + // command line contains that substring before printf runs. + require.True(t, replayContains(t, s, "\x1b", 5*time.Second), + "expected ESC bytes in PTY replay buffer") - // PTY mode should propagate ESC bytes verbatim. data, _ := s.replay.ReadFrom(0) - assert.Contains(t, string(data), "\x1b", "expected ESC bytes in PTY output") + assert.Contains(t, string(data), "GREEN", "expected GREEN text in PTY output") } func TestPTYSession_PipeMode(t *testing.T) {