diff --git a/docs/adrs/026.server.pipe.md b/docs/adrs/026.server.pipe.md new file mode 100644 index 0000000..7be6db3 --- /dev/null +++ b/docs/adrs/026.server.pipe.md @@ -0,0 +1,173 @@ +# ADR 026: Server — Execute Mode + +**SPEC:** [server.pipe](../specs/server.pipe.md) +**Status:** Accepted +**Date:** 2026-06-11 + +--- + +## Context + +webtty's current model is interactive: a session is created, a PTY is spawned, and a browser connects via WebSocket to drive the terminal in real-time. This covers the interactive use case well but is heavy for one-shot CLI invocations. + +The `claude -p` pattern — send a prompt, stream back output, exit — has no good HTTP equivalent in webtty today. A caller wanting to invoke a CLI from a browser must create a session, open a WebSocket, parse terminal escape sequences, and manage teardown. That is far more than necessary for a non-interactive run. + +A session in webtty naturally groups three modes of operation: + +| Mode | Path | Description | +|------|------|-------------| +| Interactive terminal | `/s/:id` (WS `/ws/:id/pty`) | Browser drives a live PTY | +| Publish channel | `POST /s/:id/publish` | Agent/script pushes structured output to subscribers | +| **Execute** | `POST /s/:id/execute` | Headless one-shot command, output streamed back to caller | + +Execute is the headless companion to the interactive terminal — same session context, no UI, no WebSocket. + +--- + +## Decision + +Add `POST /s/:id/execute` to the webtty HTTP server. + +### API + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/s/:id/execute` | Spawn `body.cmd` with `body.args`; stream stdout/stderr as ndjson lines; final line is `{"exit":}`; `400` if body is invalid; `404` if session does not exist; `409` if PTY is not running; `500` if the process cannot be spawned | + +### Request body + +```json +{ + "cmd": "claude", + "args": ["-p", "summarize this text"], + "stdin": "optional string piped to the process" +} +``` + +`stdin` is optional. `cmd` and `args` are required. + +### Response + +`Content-Type: application/x-ndjson`, chunked transfer encoding. + +Each line is one of: + +```json +{"stream":"stdout","data":"chunk of stdout text"} +{"stream":"stderr","data":"chunk of stderr text"} +{"exit":0} +``` + +The `{"exit":N}` line is always the last line. The response body ends immediately after it. If the caller closes the connection before the process exits, the child process is killed. + +### Browser usage + +```js +const res = await fetch('/s/my-session/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: 'claude', args: ['-p', 'hello'] }), +}); +const reader = res.body.getReader(); +const decoder = new TextDecoder(); +let buf = ''; +while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + const event = JSON.parse(line); + if (event.stream === 'stdout') render(event.data); + if (event.exit !== undefined) console.log('exit', event.exit); + } +} +``` + +### Implementation — files to touch + +1. **`src/server/routes.ts`** — add `POST /s/:id/execute` branch: + - Decode and validate session id; `404` if session not in registry + - Check `Content-Type: application/json`; `400` if not (consistent with publish) + - Check `session.pty !== null`; `409` if not running (consistent with publish) + - Parse body: validate `cmd` (string, required) and `args` (string[], required); `stdin` (string, optional) + - Set response headers: `Content-Type: application/x-ndjson`, `Transfer-Encoding: chunked`, `Cache-Control: no-cache` + - Spawn via `child_process.spawn(cmd, args, { env: process.env })`; optionally write `stdin` and close the child's stdin + - On `stdout.data`: write `{"stream":"stdout","data":""}\n` + - On `stderr.data`: write `{"stream":"stderr","data":""}\n` + - On `close(code)`: write `{"exit":}\n`, then `res.end()` + - On `req.close` before exit: kill the child process + +--- + +## Reasons + +### Session-scoped, not global + +A session in webtty represents a running terminal context. Execute is the headless mode of that same context — it shares the same named workspace as the interactive terminal and the publish channel. Scoping to `/s/:id/execute` makes the relationship explicit and keeps the API surface uniform: everything under `/s/:id/` belongs to one session. + +A global `/api/execute` would be equally functional but loses that conceptual grouping. The session ID also becomes a natural coordination point: a browser that already knows its session ID can call execute without any extra state. + +### PTY required (409 if not running) + +Execute requires an active session with a running PTY. This is consistent with the publish channel (`POST /s/:id/publish` also returns `409` when the PTY is not running) and reinforces the mental model: execute is a companion to a live terminal, not a standalone script runner. If the terminal is not running, neither mode is available. + +### No PTY for the spawned process + +The child process spawned by execute uses `child_process.spawn` with piped stdio, not a PTY. PTY would make the child think it's talking to a real terminal (`isatty() === true`), causing tools like `claude -p` to activate their interactive UI instead of pipe mode. Plain spawn gives clean stdout/stderr with no escape sequences. + +### ndjson over plain text + +Plain text streams stdout faithfully but gives the caller no way to distinguish stdout from stderr or to detect the exit code without a sentinel convention. ndjson adds one JSON parse per chunk and in return gives structured events, stderr separation, and a typed exit frame — all without a bespoke framing protocol. + +### Chunked streaming over SSE + +SSE (`text/event-stream`) requires a GET request; a POST body is the natural way to pass `cmd` and `args`. Chunked transfer encoding with `fetch` + `ReadableStream` works in all modern browsers and matches the POST semantics. + +### Kill on disconnect + +Leaving a child process running after the caller disconnects wastes resources and can produce confusing behaviour if the process writes to files or network. Killing on `req.close` is the safe default. + +### CSRF is a non-issue + +The endpoint requires `Content-Type: application/json`, which triggers a CORS preflight for cross-origin requests. Since the server does not respond with permissive CORS headers, browsers block cross-origin calls before they reach the server. No token or allowlist is needed. + +--- + +## Considered Options + +### Option A: Global endpoint (`POST /api/execute`) + +No session prerequisite — simpler for callers that do not have a session open. But it breaks the conceptual grouping: execute, publish, and the interactive terminal all belong to the same session workspace. A global endpoint is an API inconsistency with no clear benefit given that webtty always has an active session when it is running. + +Rejected — session-scoped is more consistent and the session prerequisite adds no real friction. + +### Option B: SSE (`GET /s/:id/execute` with query params) + +`EventSource` is the native browser SSE client and handles reconnection automatically. But `cmd` and `args` in query params are awkward for non-trivial invocations and GET semantics are wrong for a side-effecting operation. + +Rejected — POST + chunked streaming covers the same ground with correct semantics. + +### Option C: WebSocket (`/ws/:id/execute`) + +Full-duplex and already used in the project. Overkill for a one-shot run; adds connection setup overhead and requires a library on the browser side. + +Rejected — fetch + ReadableStream is sufficient and lighter. + +### Option D: Plain text response + +Simpler than ndjson — no per-chunk parse. But callers lose stderr separation and the exit code must be conveyed out-of-band (e.g. HTTP trailer or a sentinel line), both of which are harder to consume reliably. + +Rejected — the marginal cost of one JSON parse per chunk is worth the structured events. + +--- + +## Consequences + +- Only a new route branch in `routes.ts` — no changes to session, WebSocket, or PTY infrastructure. +- The endpoint is localhost-only (consistent with the rest of the server); no auth is added. +- `child_process` must be imported in `routes.ts` (or a new `src/server/execute.ts` helper if the handler grows large). +- Callers receive raw stdout; if `cmd` emits ANSI escape sequences, the caller is responsible for stripping them (e.g. pass `--no-color` or strip client-side). +- `409` behaviour is consistent with `POST /s/:id/publish` — both require an active PTY. diff --git a/docs/specs/server.pipe.md b/docs/specs/server.pipe.md new file mode 100644 index 0000000..1414077 --- /dev/null +++ b/docs/specs/server.pipe.md @@ -0,0 +1,74 @@ +# SPEC: Server — Execute Mode (CLI → HTTP) + +**Last Updated:** 2026-06-11 + +--- + +## Description + +A session-scoped HTTP endpoint that runs a CLI command non-interactively and streams its output back to the caller. + +**Persona:** Browser-based tools, web UIs, and scripts that need to invoke a local CLI (e.g. `claude -p`) and consume the output — without going through an interactive terminal session. + +Execute is the headless companion to the interactive terminal. A session in webtty groups three modes: + +| Mode | How | +|------|-----| +| Interactive terminal | Browser connects via WebSocket, drives the PTY live | +| Execute (headless) | Caller POSTs a command, receives streamed output | +| Publish channel | Agent/script pushes structured output to subscribers | + +--- + +## Architecture + +``` +┌─────────────────┐ WS /ws/:id/pty ┌──────────────────────┐ +│ Browser tab │ ◄──────────────────────────────── │ │ +│ (terminal) │ ──────────────────────────────── ►│ session PTY │ +└─────────────────┘ keyboard / resize │ │ + │ webtty server │ +┌─────────────────┐ POST /s/:id/execute │ │ +│ Browser / │ { cmd, args, stdin? } │ child_process │ +│ fetch client │ ──────────────────────────────── ►│ .spawn(cmd, args) │ +│ │ application/x-ndjson (chunked) │ │ +│ ReadableStream │ ◄────────────────────────────── - │ │ +│ (stdout/exit) │ {"stream":"stdout","data":"…"} │ │ +└─────────────────┘ {"exit":0} └──────────────────────┘ +``` + +Execute spawns a separate child process (not the PTY). The PTY must be running (`409` otherwise) but the command runs independently alongside it. + +--- + +## Use Cases + +### Run `claude -p` from a browser UI + +A web panel sends a prompt to `claude -p` and renders the streaming markdown response as it arrives — no terminal emulator required, no WebSocket to manage. + +### Programmatic CLI invocation + +Any browser-based tool (extension, localhost web app) that needs to call a local CLI and consume structured output in the context of a running session. + +--- + +## How It Works + +1. Caller sends `POST /s/:id/execute` with JSON body: `{ "cmd": "claude", "args": ["-p", "…"] }` +2. Server checks session exists (`404` if not) and PTY is running (`409` if not) +3. Server spawns the command via `child_process.spawn` (no PTY — plain piped stdio) +4. stdout chunks are flushed as ndjson lines: `{"stream":"stdout","data":"…"}` +5. stderr chunks (if any): `{"stream":"stderr","data":"…"}` +6. On process exit: `{"exit":0}` — then response ends +7. If the request is cancelled mid-stream, the child process is killed + +For API reference and design rationale see [ADR 026](../adrs/026.server.pipe.md). + +--- + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Execute endpoint | `POST /s/:id/execute` — spawn a CLI command alongside a running session, stream stdout/stderr as ndjson, close with exit code | [ADR 026](../adrs/026.server.pipe.md) | ☐ | diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 8c3b8e7..7667462 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -232,6 +232,100 @@ describe('server — routes', () => { expect(res.status).toBe(409); }); + test('POST /s/:id/execute returns 404 for unknown session', async () => { + const res = await fetch(`${baseUrl}/s/no-such-session/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: 'echo', args: [] }), + }); + expect(res.status).toBe(404); + }); + + test('POST /s/:id/execute returns 400 for wrong content-type', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-ct-test' }), + }); + const res = await fetch(`${baseUrl}/s/execute-ct-test/execute`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'hello', + }); + expect(res.status).toBe(400); + }); + + test('POST /s/:id/execute returns 409 when PTY not running', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-no-pty' }), + }); + const res = await fetch(`${baseUrl}/s/execute-no-pty/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: 'echo', args: [] }), + }); + expect(res.status).toBe(409); + }); + + test('POST /s/:id/execute returns 400 for invalid body', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-bad-body' }), + }); + const wsUrl = baseUrl.replace(/^http/, 'ws'); + const ws = new WebSocket(`${wsUrl}/ws/execute-bad-body/pty?cols=80&rows=24`); + await new Promise((resolve, reject) => { + ws.onmessage = () => resolve(); + ws.onerror = () => reject(new Error('WS error')); + setTimeout(() => reject(new Error('WS timeout')), 5000); + }); + const res = await fetch(`${baseUrl}/s/execute-bad-body/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: 123, args: [] }), + }); + ws.close(); + expect(res.status).toBe(400); + }); + + test('POST /s/:id/execute streams ndjson stdout and exit line', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-happy' }), + }); + const wsUrl = baseUrl.replace(/^http/, 'ws'); + const ws = new WebSocket(`${wsUrl}/ws/execute-happy/pty?cols=80&rows=24`); + await new Promise((resolve, reject) => { + ws.onmessage = () => resolve(); + ws.onerror = () => reject(new Error('WS error')); + setTimeout(() => reject(new Error('WS timeout')), 5000); + }); + const res = await fetch(`${baseUrl}/s/execute-happy/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: 'echo', args: ['hello-execute'] }), + }); + ws.close(); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/x-ndjson'); + const text = await res.text(); + const lines = text + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as Record); + const stdoutLine = lines.find((l) => l['stream'] === 'stdout'); + expect(stdoutLine).toBeDefined(); + expect(String(stdoutLine?.['data'])).toContain('hello-execute'); + const exitLine = lines.find((l) => 'exit' in l); + expect(exitLine).toBeDefined(); + expect(exitLine?.['exit']).toBe(0); + }); + test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index c9204a6..cd0d1d9 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import type http from 'node:http'; import path from 'node:path'; import { loadConfig } from '../config'; @@ -305,6 +306,79 @@ export async function handleRequest( return; } + const executeMatch = pathname.match(/^\/s\/([^/]+)\/execute$/); + if (req.method === 'POST' && executeMatch) { + const id = decodeId(executeMatch[1]); + if (!id) { + res.writeHead(400); + res.end('Bad Request'); + return; + } + if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { + res.writeHead(400); + res.end('Bad Request'); + return; + } + const session = sessionRegistry.get(id); + if (!session) { + res.writeHead(404); + res.end('Not Found'); + return; + } + if (session.pty === null) { + res.writeHead(409); + res.end('PTY not running'); + return; + } + let execBody: unknown; + try { + execBody = await readJson(req); + } catch (err) { + const status = (err as { status?: number }).status === 413 ? 413 : 400; + res.writeHead(status); + res.end(status === 413 ? 'Payload Too Large' : 'invalid JSON'); + return; + } + const { cmd, args, stdin } = execBody as { cmd?: unknown; args?: unknown; stdin?: unknown }; + if (typeof cmd !== 'string' || cmd.length === 0) { + res.writeHead(400); + res.end('invalid body'); + return; + } + if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) { + res.writeHead(400); + res.end('invalid body'); + return; + } + res.writeHead(200, { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + }); + const child = spawn(cmd, args as string[], { env: process.env }); + let childExited = false; + if (typeof stdin === 'string') { + child.stdin?.write(stdin); + } + child.stdin?.end(); + child.stdout?.on('data', (chunk: Buffer) => { + res.write(JSON.stringify({ stream: 'stdout', data: chunk.toString() }) + '\n'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + res.write(JSON.stringify({ stream: 'stderr', data: chunk.toString() }) + '\n'); + }); + child.on('close', (code: number | null) => { + childExited = true; + res.write(JSON.stringify({ exit: code ?? 1 }) + '\n'); + res.end(); + }); + req.on('close', () => { + if (!childExited) { + child.kill(); + } + }); + return; + } + const pidMatch = pathname.match(/^\/p\/(\d+)$/); if (req.method === 'GET' && pidMatch) { const pid = parseInt(pidMatch[1], 10);