Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/adrs/026.server.pipe.md
Original file line number Diff line number Diff line change
@@ -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":<code>}`; `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":"<chunk>"}\n`
- On `stderr.data`: write `{"stream":"stderr","data":"<chunk>"}\n`
- On `close(code)`: write `{"exit":<code>}\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.
74 changes: 74 additions & 0 deletions docs/specs/server.pipe.md
Original file line number Diff line number Diff line change
@@ -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) | ☐ |
94 changes: 94 additions & 0 deletions src/server/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,100 @@
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<void>((resolve, reject) => {
ws.onmessage = () => resolve();
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('WS timeout')), 5000);

Check failure on line 283 in src/server/routes.test.ts

View workflow job for this annotation

GitHub Actions / test

error: WS timeout

at <anonymous> (/__w/webtty/webtty/src/server/routes.test.ts:283:35)
});
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<void>((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<string, unknown>);
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);
Expand Down
Loading
Loading