diff --git a/docs/adrs/026.server.pipe.md b/docs/adrs/026.server.headless-execute.md similarity index 99% rename from docs/adrs/026.server.pipe.md rename to docs/adrs/026.server.headless-execute.md index 7be6db3..7032145 100644 --- a/docs/adrs/026.server.pipe.md +++ b/docs/adrs/026.server.headless-execute.md @@ -1,6 +1,6 @@ # ADR 026: Server — Execute Mode -**SPEC:** [server.pipe](../specs/server.pipe.md) +**SPEC:** [headless-execute](../specs/headless-execute.md) **Status:** Accepted **Date:** 2026-06-11 diff --git a/docs/adrs/027.server.execute-resilience.md b/docs/adrs/027.server.execute-resilience.md new file mode 100644 index 0000000..dd8692a --- /dev/null +++ b/docs/adrs/027.server.execute-resilience.md @@ -0,0 +1,212 @@ +# ADR 027: Server — Execute Resilience & Structured Error Protocol + +**Status**: Accepted +**Date**: 2026-06-20 +**Spec**: [Server — Headless Execute](../specs/headless-execute.md) + +--- + +## 1. Decision Record + + + +### Context + +The `POST /s/:id/execute` endpoint introduced in ADR 026 spawns a child process and streams its output as NDJSON. Two gaps make the current implementation unsafe for production agent workflows: + +**Gap 1 — Server crash on spawn failure.** If the requested command is not found (`ENOENT`) or cannot be executed (`EACCES`), Node.js emits an `error` event on the child process. The handler in ADR 026 has no `child.on('error', …)` listener, so this becomes an unhandled EventEmitter error — which Node.js converts to an uncaught exception and terminates the process. One bad tool call from any agent kills the server for every other user. + +**Gap 2 — Untyped errors in the NDJSON stream.** When the command exits non-zero or spawn itself fails, the caller currently receives only raw stderr text and a numeric exit code. An agent cannot distinguish "the command ran and produced a meaningful error" from "the command never started because it doesn't exist". Both look like `exit: 1` with text in stderr. Without a typed signal, the agent must parse free-form error messages — fragile and brittle across OS and shell versions. + +Additionally, `handleRequest` is an `async` function called inside `http.createServer` with no `.catch()`. A rejected promise from any route handler silently becomes an unhandled rejection, which in Node.js ≥ 15 terminates the process by default. + +### Decision + +Two decisions are made together because they form a single coherent contract: the server stays alive, and the agent always gets a machine-readable error signal. + +**Decision 1 — Process-level resilience.** Register `uncaughtException` and `unhandledRejection` handlers in `src/server/index.ts` that log the error and do not exit. Wrap the `handleRequest` call in `.catch()` to ensure no promise rejection escapes. These are last-resort guards — not a substitute for proper per-handler error handling. + +**Decision 2 — Spawn-level resilience in the execute handler.** In `POST /s/:id/execute`: move `res.writeHead(200, …)` to after the spawn attempt so that synchronous spawn failures can return HTTP 500 before headers are committed. Add `child.on('error', …)` to handle asynchronous spawn errors (ENOENT, EACCES). Use a `done` flag to prevent `res.end()` from being called by both the `error` and `close` handlers. + +**Decision 3 — Structured error on the exit NDJSON line.** When spawn fails (not when the command simply exits non-zero), the final `{ exit }` line carries two additional fields: `error` (human-readable message) and `code` (the Node.js error code string, e.g. `ENOENT`). A non-zero exit from the command itself produces only `{ exit: N }` — no `error` or `code` fields. This one-line distinction gives agents a stable predicate to branch on without parsing stderr text. + +### Considered Options + +#### Option A — Process-level catch-all only + +Register `uncaughtException` / `unhandledRejection` at process level and rely on stderr text for error diagnosis. No changes to the NDJSON stream shape. + +**Pros:** Minimal diff. Server stays alive. +**Cons:** Agents still cannot distinguish spawn failure from command failure without text parsing. The catch-all is a backstop, not a contract. + +#### Option B — Per-handler guard + structured exit line (chosen) + +Fix the execute handler directly (`child.on('error')`, `done` flag, `writeHead` moved), and extend the exit NDJSON line with `error` and `code` fields on spawn failure. Add process-level guards as a second safety net. + +**Pros:** Agent gets a stable, typed signal. Server never crashes. No text parsing required. Backwards-compatible — the `{ exit }` field is unchanged; new fields are additive. +**Cons:** Callers must handle the new fields explicitly to benefit from them. Adds one branch in the execute handler. + +#### Option C — Separate `{ type: "error" }` NDJSON line + +Emit a distinct NDJSON line `{ type: "error", code: "ENOENT", message: "…" }` before the `{ exit: 1 }` line. + +**Pros:** Separates the error signal from the exit signal. Clearer for stream parsers that process line-by-line. +**Cons:** Parsers must now handle a third event type. Existing consumers that only watch for `exit` would ignore the error line entirely, giving no benefit unless they are updated. Option B adds signal to the existing terminal event, which every consumer already reads. + +### Rationale + +Option B is chosen because it adds machine-readable error context to the one line every consumer already waits for — the exit line. Agents do not need code changes to remain correct (the `exit` field is unchanged); they opt in to the richer signal by also checking `code`. The process-level guards are a necessary complement, not a substitute: a catch-all that swallows errors without telling the caller is still a failure mode for the agent. + +### Consequences + +**Positive** +- webtty never terminates due to a bad tool call from an agent. +- Agents can branch on `code === 'ENOENT'` to surface actionable guidance ("install the CLI") instead of forwarding raw stderr text. +- Backwards-compatible: callers that ignore `error`/`code` on the exit line continue to work unchanged. + +**Negative** +- Agents must be updated to consume `code` from the exit line to get the benefit. The current agent (cliIntegrationAgentChatService) reads only `exit` and `data` — it gains resilience but not structured branching until updated. + +**Risks** +- Node.js error codes (ENOENT, EACCES, etc.) are OS-level and stable, but not exhaustive. An unknown code should be treated as a generic spawn failure. Agents should not switch exhaustively on `code`. + +--- + +## 2. Functional Specification + + + +### Scenarios + +**Scenario 1 — Command not found (ENOENT)** + +1. Agent POSTs `{ cmd: "tc", args: ["search", "*"] }` to `/s/main/execute`. +2. `tc` is not on `PATH`. +3. Server spawns the process; Node.js emits `error` on the child with code `ENOENT`. +4. Handler writes `{ stream: "stderr", data: "spawn ENOENT: …\n" }` to the response. +5. Handler writes `{ exit: 1, error: "spawn ENOENT: …", code: "ENOENT" }` and ends. +6. webtty continues serving other requests. Server does not exit. +7. Agent reads `exit: 1, code: "ENOENT"` and surfaces: *"tc is not installed — run: bunx @awc/cli@next …"* + +**Scenario 2 — Command exits non-zero** + +1. Agent POSTs `{ cmd: "tc", args: ["search", "bad-query"] }`. +2. `tc` runs, returns exit code 2. +3. Handler writes stderr chunks as they arrive, then `{ exit: 2 }`. +4. No `error` or `code` fields on the exit line — this is a command-level failure, not a spawn failure. +5. Agent reads `exit: 2`, inspects stderr, and decides whether to retry or surface the error. + +**Scenario 3 — Unhandled exception in a route handler** + +1. A route handler throws an unexpected exception. +2. The `.catch()` wrapper in `index.ts` catches the rejected promise. +3. Server writes `500 Internal Server Error` and continues. +4. `uncaughtException` guard (last resort) logs and does not call `process.exit`. + +### Non-Goals + +- This ADR does not define agent-side error handling strategy — that is up to each consumer. +- This ADR does not add structured error fields to the `publish` endpoint or WebSocket channel. +- This ADR does not address authentication or rate-limiting on the execute endpoint. + +### Data Contracts + +**Execute NDJSON stream — extended exit line** + +``` +{ "stream": "stdout", "data": "…" } // zero or more stdout chunks +{ "stream": "stderr", "data": "…" } // zero or more stderr chunks +{ "exit": 0 } // command exited successfully +{ "exit": 1 } // command exited with error (no spawn issue) +{ "exit": 1, "error": "…", "code": "ENOENT" } // spawn itself failed +``` + +`error` and `code` are present **only** when spawn fails (the child process never started). A non-zero exit from a running command produces `{ exit: N }` with no additional fields. + +`code` is the Node.js `SystemError.code` string (e.g. `ENOENT`, `EACCES`, `EPERM`). Callers should treat unrecognised codes as generic spawn failures. + +--- + +## 3. Implementation Specification + + + +### Implementation Detail + +**`src/server/index.ts` — process-level guards** + +Add before `httpServer.listen`: + +```ts +process.on('uncaughtException', (err) => { + console.error('[webtty] uncaughtException:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[webtty] unhandledRejection:', reason); +}); +``` + +Wrap the `handleRequest` call in the server callback: + +```ts +const httpServer = http.createServer((req, res) => { + handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown).catch((err) => { + console.error('[webtty] unhandled route error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); +}); +``` + +**`src/server/routes.ts` — execute handler hardening** + +Replace the spawn block (currently lines 371–397) with: + +```ts +let child: ReturnType; +try { + child = spawn(cmd, args as string[], { env: process.env }); +} catch (err) { + res.writeHead(500); + res.end(`spawn error: ${String(err)}`); + return; +} +res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' }); +let done = false; +child.on('error', (err) => { + if (done) return; + done = true; + const msg = err.message; + const code = (err as NodeJS.ErrnoException).code; + res.write(JSON.stringify({ stream: 'stderr', data: `${msg}\n` }) + '\n'); + res.write(JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) }) + '\n'); + res.end(); +}); +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) => { + if (done) return; + done = true; + res.write(JSON.stringify({ exit: code ?? 1 }) + '\n'); + res.end(); +}); +req.on('close', () => { if (!done) child.kill(); }); +``` + +Key invariants: +- `writeHead(200)` only fires after `spawn` succeeds — synchronous spawn errors return `500` with no NDJSON. +- `done` flag ensures exactly one `res.end()` call regardless of whether `error` or `close` fires first (both can fire on ENOENT). +- `error` and `code` appear on the exit line only when the child's `error` event fires — never on a normal non-zero exit. + +### Related Decisions + +- [ADR 026 — Server Execute Mode](026.server.headless-execute.md) — the endpoint this ADR hardens +- [ADR 025 — Session Channel](025.server.channel.md) — publish/events channel on the same session diff --git a/docs/adrs/028.session.base-dir.md b/docs/adrs/028.session.base-dir.md new file mode 100644 index 0000000..1b82fea --- /dev/null +++ b/docs/adrs/028.session.base-dir.md @@ -0,0 +1,319 @@ +# ADR 028: Session — Base Directory + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [webtty](../specs/webtty.md) + +--- + +## 1. Decision Record + + + +### Context + +When a PTY session is spawned, the shell starts in the user's home directory (`homedir()`), hardcoded in both `src/pty/node.ts` and `src/pty/bun.ts`. There is no way for a caller — via the CLI or the HTTP API — to specify a different starting directory. + +This matters for project-scoped workflows: opening `webtty` from a project directory should start the shell there, not in `~`. Currently, the user must manually `cd` into the project after the shell opens. + +Two surfaces need to support `baseDir`: + +- **HTTP API** (`POST /api/sessions`): allows programmatic callers and agents to set the directory when creating a session. +- **CLI** (`webtty [go] [id]`): allows developers to pass `--dir ` from the terminal, typically as `.` or an absolute project path. + +`baseDir` applies only when a **new** session is created. If the session already exists, the field is silently ignored — the shell's working directory is already live. + +The current default (`homedir()`) is correct and should be preserved when `baseDir` is not specified. + +### Decision + +**Decision 1 — Store `baseDir` on the `Session` struct.** +Add a `baseDir: string` field to `Session` (default: `homedir()`). It is set once at `createSession` time and never mutated. It is used when the PTY is first spawned (lazy, on first WebSocket connect). + +**Decision 2 — Accept `baseDir` in `POST /api/sessions`.** +Extend the request body to `{ id?: string, baseDir?: string }`. The server validates that `baseDir`, if supplied, is an absolute path that exists on disk (`fs.existsSync`). Invalid values return `400`. The value is stored on the session and returned in the `GET /api/sessions` and `GET /api/sessions/:id` responses. + +**Decision 3 — Pass `baseDir` through the PTY spawn chain.** +`createSession`, `spawnForSession`, and the underlying `spawn` in both `src/pty/node.ts` and `src/pty/bun.ts` all receive a `cwd` parameter. The hardcoded `homedir()` calls in the PTY implementations are removed; callers supply `cwd` explicitly. + +**Decision 4 — Add `--dir ` to the CLI.** +`webtty [go] [id] [--dir ]` parses `--dir` from `rest` in `cmdGo`. The path is resolved to an absolute path relative to `process.cwd()` of the CLI process before it is sent to the API. This allows `webtty --dir .` to work naturally from a project directory. + +### Considered Options + +#### Option A — Resolve relative paths server-side + +Accept relative paths in the API and resolve them relative to the server process's `cwd`. + +**Pros:** Simpler API — callers don't need to know the server's location. +**Cons:** The server's `cwd` is unrelated to the caller's intent. `--dir .` sent as-is would resolve to wherever the server was launched, not where the CLI was invoked. Confusing and error-prone. + +#### Option B — Require absolute paths in the API; resolve on the CLI (chosen) + +Require absolute paths at the API boundary. The CLI resolves relative paths (`path.resolve`) before sending. Other HTTP callers (agents, scripts) must send absolute paths. + +**Pros:** No ambiguity. The server validates a concrete path. The CLI transparently handles the common `--dir .` case. +**Cons:** Programmatic callers must resolve paths themselves. + +#### Option C — Accept `baseDir` on the WebSocket URL instead + +Pass `?cwd=…` on `GET /ws/:id/pty` so the directory is set at PTY-spawn time, not session-creation time. + +**Pros:** No session struct change needed. +**Cons:** The directory would need to match across reconnects. Storing it on the session (Option B) is the natural place — it belongs to the session, not the connection. + +### Rationale + +Option B is chosen because `baseDir` is a session-level concept, not a per-connection concept. Tying it to session creation is the right abstraction: the directory is set once, is part of the session's identity, and is visible in the session listing for diagnostics. Resolving on the CLI keeps the server logic simple and the API contract clear. + +### Consequences + +**Positive** +- `webtty --dir .` opens a terminal rooted in the current project directory. +- Agents creating sessions via `POST /api/sessions` can pin a working directory. +- The session list (`GET /api/sessions`) exposes `baseDir` for observability. +- Default behaviour (`homedir()`) is unchanged for callers that omit `baseDir`. + +**Negative** +- The `spawn` function signatures in both PTY implementations gain a `cwd` parameter — a breaking internal change, but these are not public APIs. +- Agents sending relative paths to the API will get a `400`; they must resolve first. + +**Non-goals** +- `baseDir` cannot be changed after session creation. Reopening the session in a different directory requires creating a new session. +- `POST /s/:id/execute` does not accept a per-request `cwd` override. Commands always run in `session.baseDir`. Callers that need a different directory can wrap the command (`sh -c "cd /other && …"`) or create a new session. + +--- + +## 2. Functional Specification + + + +### Default Behaviour + +When `baseDir` is not specified, it defaults to `homedir()`. This matches the current behaviour exactly. + +### Scenarios + +**Scenario 1 — CLI: open a project directory** + +1. Developer runs `webtty --dir .` from `/Users/alice/projects/myapp`. +2. CLI resolves `.` → `/Users/alice/projects/myapp` and POSTs `{ id: "main", baseDir: "/Users/alice/projects/myapp" }`. +3. Server creates session `main` with `baseDir = "/Users/alice/projects/myapp"`. +4. Browser connects; PTY spawns shell with `cwd = "/Users/alice/projects/myapp"`. +5. Shell prompt shows `~/projects/myapp`. + +**Scenario 2 — Session already exists, `--dir` ignored** + +1. Developer runs `webtty --dir /tmp` but session `main` already exists. +2. CLI checks `GET /api/sessions/main` → 200. Session exists; CLI skips the POST entirely. +3. Browser opens the existing session. `baseDir` from the CLI arg is never sent. + +**Scenario 3 — API: agent creates a scoped session** + +1. Agent POSTs `{ id: "proj-abc", baseDir: "/workspace/proj-abc" }` to `/api/sessions`. +2. Server validates path exists. Returns `201` with `{ id: "proj-abc", baseDir: "/workspace/proj-abc", … }`. +3. PTY spawns in `/workspace/proj-abc` when the WebSocket connects. + +**Scenario 4 — Invalid baseDir** + +1. Caller POSTs `{ baseDir: "relative/path" }`. +2. Server returns `400` with `{ error: "baseDir must be an absolute path" }`. + +1. Caller POSTs `{ baseDir: "/does/not/exist" }`. +2. Server returns `400` with `{ error: "baseDir does not exist: /does/not/exist" }`. + +### Data Contracts + +**`POST /api/sessions` — request body** + +```json +{ "id": "myproject", "baseDir": "/Users/alice/projects/myapp" } +``` + +`baseDir` is optional. If omitted, defaults to `homedir()`. Must be an absolute path that exists on the server's filesystem. + +**`GET /api/sessions` / `GET /api/sessions/:id` — response** + +```json +{ + "id": "myproject", + "createdAt": 1750000000000, + "connected": false, + "pid": null, + "baseDir": "/Users/alice/projects/myapp" +} +``` + +`baseDir` is always present in session responses (defaulting to `homedir()`). + +**CLI flag** + +``` +webtty [go] [id] [--dir ] +``` + +`--dir` accepts absolute or relative paths. Relative paths are resolved against `process.cwd()` of the CLI process before being sent to the API. Ignored if the session already exists. + +--- + +## 3. Implementation Specification + + + +### Files to change + +**`src/server/session.ts`** + +Add `baseDir: string` to the `Session` interface. Update `createSession` to accept an optional second argument: + +```ts +import { homedir } from 'node:os'; + +export interface Session { + id: string; + createdAt: number; + baseDir: string; // ← new + pty: PtyProcess | null; + clients: Set; + subscribers: Set; + scrollback: string; +} + +export function createSession(id: string, baseDir = homedir()): Session { + const session: Session = { + id, + createdAt: Date.now(), + baseDir, // ← new + pty: null, + clients: new Set(), + subscribers: new Set(), + scrollback: '', + }; + sessionRegistry.set(id, session); + return session; +} +``` + +Add `baseDir` to `sessionToJson`: + +```ts +export function sessionToJson(s: Session) { + return { + id: s.id, + createdAt: s.createdAt, + connected: s.clients.size > 0, + pid: s.pty?.pid ?? null, + baseDir: s.baseDir, // ← new + }; +} +``` + +**`src/server/routes.ts` — `POST /api/sessions`** + +Extend the body type and add validation before `createSession`: + +```ts +let body: { id?: string; baseDir?: string }; +// … existing readJson/catch block … + +const baseDir = body.baseDir ?? homedir(); +if (!path.isAbsolute(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'baseDir must be an absolute path' })); + return; +} +if (!fs.existsSync(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); + return; +} +const session = createSession(id, baseDir); +``` + +Add `import fs from 'node:fs'` at the top of the file. `path` is already imported. + +**`src/pty/index.ts` — `spawnForSession`** + +Add `cwd` parameter, pass through to `_spawn`: + +```ts +export function spawnForSession( + cols: number, + rows: number, + shell: string, + term: string, + colorTerm: string, + cwd: string, // ← new +) { + return _spawn(shell, cols, rows, term, colorTerm, cwd); +} +``` + +**`src/pty/node.ts` and `src/pty/bun.ts`** + +Add `cwd: string` as the last parameter to `spawn`. Replace `cwd: homedir()` with `cwd` in the spawn options. Remove the `homedir` import if it is no longer used. + +```ts +export function spawn( + shell: string, + cols: number, + rows: number, + term: string, + colorTerm: string, + cwd: string, // ← new (was homedir() inline) +): PtyProcess { +``` + +**`src/server/websocket.ts`** + +Pass `session.baseDir` to `spawnForSession`: + +```ts +session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir); +``` + +**`src/cli/index.ts`** + +Extract `--dir` from the raw argv *before* splitting `cmd`/`rest`, so that `webtty --dir .` is not mistakenly treated as an unknown command named `--dir`: + +```ts +const allArgs = process.argv.slice(2); +const dirFlagIdx = allArgs.indexOf('--dir'); +let cliBaseDir: string | undefined; +if (dirFlagIdx !== -1) { + cliBaseDir = path.resolve(allArgs[dirFlagIdx + 1] ?? '.'); + allArgs.splice(dirFlagIdx, 2); +} +const [cmd, ...rest] = allArgs; + +if (!cmd) { + await cmdGo('main', cliBaseDir); +} else if (GO_ALIASES.has(cmd)) { + await cmdGo(rest[0] ?? 'main', cliBaseDir); +} +``` + +Add `import path from 'node:path'` at the top of `index.ts`. + +**`src/cli/commands.ts` — `cmdGo`** + +Accept `baseDir` directly (already resolved to absolute by `index.ts`): + +```ts +export async function cmdGo(id = 'main', baseDir?: string): Promise { + // … + // In the POST /api/sessions body (only when creating a new session): + body: JSON.stringify({ id, ...(baseDir ? { baseDir } : {}) }), +``` + +### Key invariants + +- `baseDir` is validated at request time (API) or resolved at call time (CLI) — the PTY spawn never sees an invalid or relative path. +- Hardcoded `homedir()` is removed from both PTY implementations; the default lives only in `createSession`. +- Callers that omit `baseDir` observe no behaviour change. + +### Related Decisions + +- [ADR 004 — Session API](004.webtty.session-api.md) — original session creation contract +- [ADR 006 — CLI Session Management](006.cli.session-management.md) — `cmdGo` and session lifecycle +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — execute endpoint (baseDir is a non-goal there) diff --git a/docs/adrs/029.cli.exec.md b/docs/adrs/029.cli.exec.md new file mode 100644 index 0000000..23b28f3 --- /dev/null +++ b/docs/adrs/029.cli.exec.md @@ -0,0 +1,274 @@ +# ADR 029: CLI — `run` command + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [CLI](../specs/cli.md) + +--- + +## 1. Decision Record + +### Context + +Two related needs share the same CLI surface: + +1. **Headless command execution.** The `POST /s/:id/execute` endpoint lets any HTTP caller run a CLI command inside a running session. There is no CLI surface for it — developers must use `curl` or write code to drive it. + +2. **Session warm-up without a browser.** `webtty go ` creates a session and opens a browser. In scripted or CI workflows, you often want to start a session and its PTY so that subsequent `run ` calls work — but without a browser window appearing. + +Both needs share a subject (``) and differ only in whether a command follows. Unifying them under `run` keeps the CLI surface small and the mental model simple. + +### Decision + +**Decision 1 — Add `webtty run [cmd [args...]]` subcommand.** +`id` is always required. `cmd` is optional: its presence determines the mode. + +**Decision 2 — `run ` (no command): start PTY without browser.** +Auto-starts the server if not running (same as `go`). Creates the session if it does not exist. Connects to the PTY WebSocket (`/ws/:id/pty?cols=80&rows=24`) to trigger PTY spawn, then disconnects immediately — the PTY keeps running. Prints the session URL to stdout but does not open the browser. If the session already exists the create step is skipped; PTY start is still attempted so repeated calls are idempotent. + +**Decision 3 — `run [args...]` (with command): headless execute.** +Server must already be running. PTY must already be running (409 otherwise). POSTs to `/s/:id/execute`, streams stdout/stderr directly, exits with the command's exit code. Does not auto-start server — this mode is for scripting where setup is explicit. + +**Decision 4 — Variadic args; no quoting required.** +The shell already splits tokens before Node.js receives `process.argv`. `webtty run main gh pr list --state open` arrives as `["gh", "pr", "list", "--state", "open"]` with no extra parsing. Quoted args with spaces work automatically. + +**Decision 5 — Stream stdout/stderr directly; exit with command's exit code.** +NDJSON lines with `stream: "stdout"` are written to `process.stdout`; `stream: "stderr"` to `process.stderr`. On the `{ exit }` line the CLI calls `process.exit(code)`. If the exit line carries `error`/`code` fields (spawn failure — ADR 027), the error is printed to stderr before exiting. + +### Considered Options + +#### Option A — Separate `exec` and `start` subcommands + +`webtty exec ` for headless execution, `webtty start ` for PTY warm-up. + +**Pros:** Unambiguous, each subcommand has a single responsibility. +**Cons:** Two new surface-area entries for closely related operations. `start` conflicts with the existing `webtty start` (server start). More to type and remember. + +#### Option B — Unified `run [cmd...]` (chosen) + +One subcommand, two modes based on whether a command follows. + +**Pros:** Mirrors how `ssh host [cmd]` works — familiar to developers. Single entry in help. Less surface area. +**Cons:** Two behaviours under one name. Mitigated by clear documentation and consistent id-first convention. + +### Consequences + +**Positive** +- `webtty run ` warms up a session for scripted use without touching the browser. +- `webtty run ` drives `POST /s/:id/execute` directly from the terminal. +- PTY warm-up is idempotent — safe to call multiple times. +- Mirrors `ssh host [cmd]` — a pattern developers already know. + +**Negative** +- The two modes behave differently on server auto-start (warm-up auto-starts; exec does not). This is intentional but worth documenting. + +--- + +## 2. Functional Specification + +### Command syntax + +``` +webtty run # warm up: start PTY, print URL, no browser +webtty run [args...] # headless exec: run command, stream output +``` + +| Token | Required | Description | +|-------|----------|-------------| +| `id` | yes | Session ID | +| `cmd` | no | If present: executable to run headlessly | +| `args` | no | Zero or more arguments passed to `cmd` | + +### Warm-up mode (`run `, no command) + +1. Start server if not running. +2. Create session `` if it does not exist; skip creation if it does. +3. Connect to `ws://127.0.0.1:/ws//pty?cols=80&rows=24`. +4. On WebSocket open: PTY is now running. Close the WebSocket and exit. +5. Print session URL to stdout: `http://:/s/`. + +### Exec mode (`run [args...]`) + +1. If server not running → print `webtty: server is not running`, exit 1. +2. If session not found (404) → print `webtty: session not found: `, exit 1. +3. If PTY not running (409) → print `webtty: PTY not running for session: `, exit 1. +4. Stream stdout → `process.stdout`, stderr → `process.stderr`. +5. On exit line: exit with reported code. +6. On spawn failure (`error`/`code` on exit line): print error to stderr, exit 1. + +### Examples + +```bash +# Warm up a session (no browser) +webtty run myproject + +# Then run commands in it +webtty run myproject gh pr list --state open +webtty run myproject claude -p "summarise this file" < notes.txt + +# Typical script pattern +webtty run myproject # start PTY +webtty run myproject npm run build # run build +webtty run myproject npm test # run tests + +# Compose with pipes (shell handles splitting) +webtty run main cat package.json | jq '.version' +``` + +### Error messages + +| Condition | Message | +|-----------|---------| +| Server not running (exec mode) | `webtty: server is not running` | +| Session not found | `webtty: session not found: ` | +| PTY not running | `webtty: PTY not running for session: ` | +| WebSocket connection failed (warm-up) | `webtty: failed to start PTY: ` | +| Spawn failure (ENOENT) | `webtty: spawn tc: No such file or directory` | +| Other HTTP error | `webtty: run failed ()` | + +--- + +## 3. Implementation Specification + +### `src/cli/commands.ts` — add `cmdRun` + +```ts +import { WebSocket } from 'ws'; + +export async function cmdRun(id: string, cmd?: string, args: string[] = []): Promise { + if (!cmd) { + // Warm-up mode: start server + session + PTY, no browser + if (!(await isServerRunning())) { + await startServer(); + } + + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); + if (check.status !== 200) { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); + process.exit(1); + } + } + + const wsUrl = `ws://127.0.0.1:${getPort()}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let opened = false; + ws.on('open', () => { + opened = true; + ws.close(1000); + resolve(); + }); + ws.on('error', (err) => reject(err)); + ws.on('close', (code) => { + if (!opened) reject(new Error(`failed to start PTY (code ${code})`)); + }); + }).catch((err: Error) => { + console.error(`webtty: failed to start PTY: ${err.message}`); + process.exit(1); + }); + + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${id}`; + console.log(url); + return; + } + + // Exec mode: headless command execution + if (!(await isServerRunning())) { + console.error('webtty: server is not running'); + process.exit(1); + } + + let res: Response; + try { + res = await fetch(`${getBaseUrl()}/s/${encodeURIComponent(id)}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd, args }), + }); + } catch { + console.error('webtty: server is not running'); + process.exit(1); + } + + if (res.status === 404) { + console.error(`webtty: session not found: ${id}`); + process.exit(1); + } + if (res.status === 409) { + console.error(`webtty: PTY not running for session: ${id}`); + process.exit(1); + } + if (!res.ok || !res.body) { + console.error(`webtty: run failed (${res.status})`); + process.exit(1); + } + + 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) continue; + const msg = JSON.parse(line) as Record; + if (msg['stream'] === 'stdout') process.stdout.write(String(msg['data'])); + else if (msg['stream'] === 'stderr') process.stderr.write(String(msg['data'])); + else if ('exit' in msg) { + if (msg['error']) console.error(`webtty: ${msg['error']}`); + process.exit((msg['exit'] as number) ?? 1); + } + } + } +} +``` + +### `src/cli/index.ts` — register `run` + +Replace `cmdExec` with `cmdRun` in the import from `./commands`. + +Update help text (fits within `col = 18`): + +```ts +row('run [cmd]', 'Start PTY (no browser) or run a command in a session'), +``` + +Replace `case 'exec'` with: + +```ts +case 'run': { + const [id, cmd, ...runArgs] = rest; + if (!id) { + console.error('usage: webtty run [cmd [args...]]'); + process.exit(1); + } + await cmdRun(id, cmd, runArgs); + break; +} +``` + +### Key invariants + +- Warm-up mode auto-starts the server; exec mode does not. +- Warm-up is idempotent: if the session or PTY already exists, the relevant steps are skipped silently. +- WebSocket connect/disconnect in warm-up does not kill the PTY — the PTY keeps running when all clients disconnect. +- `id`, `cmd`, and `args` are passed as-is to the HTTP body in exec mode — no shell parsing. +- Exit code is always forwarded in exec mode. + +### Related decisions + +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — HTTP endpoint driven by exec mode +- [ADR 027 — Execute Resilience](027.server.execute-resilience.md) — structured error on the exit line +- [ADR 028 — Session Base Directory](028.session.base-dir.md) — commands run in `session.baseDir` +- [ADR 006 — CLI Session Management](006.cli.session-management.md) — existing CLI pattern diff --git a/docs/adrs/030.config.env-inject.md b/docs/adrs/030.config.env-inject.md new file mode 100644 index 0000000..0620ec4 --- /dev/null +++ b/docs/adrs/030.config.env-inject.md @@ -0,0 +1,229 @@ +# ADR 030: Config — Environment Variable Injection + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [Config](../specs/config.md) + +--- + +## 1. Decision Record + +### Context + +PTY shells and execute commands currently inherit only `process.env` — the environment of the webtty server process. There is no way to inject additional environment variables (e.g. `ANTHROPIC_API_KEY`, project tokens, tool flags) without setting them in the shell profile or passing them at server startup. + +This is inconvenient for agent-oriented workflows where a browser-launched terminal needs credentials that are not in the user's default shell env (e.g. when webtty is started from a GUI launcher rather than a terminal). It is also inconsistent with the established contract in `~/.claude/settings.json`, which uses a flat `env` object to inject vars into every Claude Code session. + +### Decision + +**Decision 1 — Add `env` to `~/.config/webtty/config.json`, same contract as Claude.** +The format is a flat `Record` under the key `"env"`, identical to `~/.claude/settings.json`: + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-…", + "MY_TOKEN": "abc123" + } +} +``` + +Non-string values are silently ignored at load time, consistent with how webtty handles other invalid config fields. + +**Decision 2 — Apply to both PTY shells and execute commands.** +Both surfaces run user commands and need the same credentials. Applying to one but not the other would create a confusing split where `webtty exec` behaves differently from the interactive shell. + +**Decision 3 — Merge order: `process.env` → `config.env` → terminal vars.** +`config.env` overrides inherited process env, but `TERM` and `COLORTERM` (set by webtty for terminal compatibility) always win and cannot be overridden via `config.env`. Merge order: + +``` +{ ...process.env, ...config.env, TERM: term, COLORTERM: colorTerm } +``` + +For execute commands (which do not set `TERM`/`COLORTERM`): + +``` +{ ...process.env, ...config.env } +``` + +**Decision 4 — Thread `env` through the PTY spawn chain as a parameter.** +`spawnForSession` and the underlying `spawn` in `pty/node.ts` and `pty/bun.ts` gain an `env` parameter. This keeps the PTY layer free of config coupling — callers (currently only `websocket.ts`) supply the env from `config.env`. + +### Considered Options + +#### Option A — Read config inside the PTY `spawn` function + +Have `spawn` call `loadConfig()` itself and read `config.env` there. + +**Pros:** No signature change. +**Cons:** Breaks the separation between the PTY layer and the config layer. Makes the PTY functions harder to test in isolation. `websocket.ts` already calls `loadConfig()` before spawning; a second call inside `spawn` is redundant. + +#### Option B — Pass `env` as a parameter (chosen) + +Caller reads `config.env` and passes it to `spawnForSession`. + +**Pros:** Clean separation. `spawn` remains a pure function of its arguments. Consistent with how `cwd` was threaded through in ADR 028. +**Cons:** One more parameter in the spawn chain (already has `cwd` from ADR 028 — same pattern). + +### Consequences + +**Positive** +- Credentials and tool-specific vars can be set once in `config.json` rather than in shell profiles or server startup env. +- Consistent contract with `~/.claude/settings.json` — users who know Claude's pattern immediately understand webtty's. +- Both PTY and execute surfaces behave identically with respect to env. + +**Negative** +- Sensitive values (API keys) are stored in a plain JSON file. Users are responsible for setting appropriate file permissions (`chmod 600 ~/.config/webtty/config.json`). This is the same responsibility as `~/.claude/settings.json`. +- `TERM` and `COLORTERM` cannot be overridden via `config.env` — this is intentional but may surprise users who try. + +--- + +## 2. Functional Specification + +### Config file format + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-…", + "NODE_ENV": "development" + } +} +``` + +`env` is optional. Default is `{}` (empty — no additional vars). Keys and values must both be strings; any entry where either is not a string is silently dropped. + +### Merge behaviour + +| Source | Priority | +|--------|----------| +| `process.env` (server's inherited env) | lowest | +| `config.env` | overrides above | +| `TERM`, `COLORTERM` (PTY only) | always highest — cannot be overridden | + +### Scope + +| Surface | `config.env` applied? | +|---------|----------------------| +| PTY shell spawn | ✓ | +| `POST /s/:id/execute` commands | ✓ | + +### Non-goals + +- Per-session env overrides — `config.env` applies globally to all sessions. Session-level env is a future concern. +- Per-request env in `POST /s/:id/execute` — callers that need per-invocation vars can pass them as CLI flags to the command itself. +- Encrypting values at rest — out of scope; same stance as `~/.claude/settings.json`. + +--- + +## 3. Implementation Specification + +### `src/config.ts` + +Add `env` to the `Config` interface: + +```ts +/** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ +env: Record; +``` + +Add to `DEFAULT_CONFIG`: + +```ts +env: {}, +``` + +Add parsing in `loadConfig()` after the `keyboardBindings` block: + +```ts +...(p.env && + typeof p.env === 'object' && + !Array.isArray(p.env) && { + env: Object.fromEntries( + Object.entries(p.env as Record).filter( + ([k, v]) => typeof k === 'string' && typeof v === 'string', + ) as [string, string][], + ), + }), +``` + +### `src/pty/node.ts` and `src/pty/bun.ts` + +Add `env: Record` as a new parameter to `spawn` (after `cwd`). Merge it into the spawn env: + +```ts +// node.ts +export function spawn(shell, cols, rows, term, colorTerm, cwd, env): PtyProcess { + const ptyProc = nodePty.spawn(shell, shellArgs, { + name: term, + cols, + rows, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + }); +``` + +```ts +// bun.ts +export function spawn(shell, cols, rows, term, colorTerm, cwd, env): PtyProcess { + const proc = Bun.spawn([shell], { + terminal: { cols, rows, data(...) { ... } }, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + }); +``` + +### `src/pty/index.ts` + +Add `env: Record` to `spawnForSession` and pass through: + +```ts +export function spawnForSession(cols, rows, shell, term, colorTerm, cwd, env: Record) { + return _spawn(shell, cols, rows, term, colorTerm, cwd, env); +} +``` + +### `src/server/websocket.ts` + +Pass `config.env` to `spawnForSession`: + +```ts +const config = loadConfig(); +session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir, config.env); +``` + +### `src/server/routes.ts` — execute handler + +`loadConfig` is already imported at the top of the file. Each route handler is its own scope — add a local call inside the execute handler, just before the spawn: + +```ts +const config = loadConfig(); +child = spawn(cmd, args as string[], { env: { ...process.env, ...config.env }, cwd: session.baseDir }); +``` + +### `src/pty/index.test.ts` — fix pre-existing missing args + +The test file calls `spawnForSession` without `cwd` (omitted when ADR 028 was implemented). After this ADR adds `env`, both params will be absent. Fix both call sites: + +```ts +// before (broken since ADR 028): +spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor') + +// after: +spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', homedir(), {}) +``` + +Add `import { homedir } from 'node:os'` at the top of the test file. + +### Key invariants + +- `TERM` and `COLORTERM` are always set after `config.env` in PTY spawns — they cannot be overridden. +- For execute commands, no `TERM`/`COLORTERM` override is applied — `config.env` values for those keys would take effect, which is harmless. +- An empty or absent `env` in config is equivalent to `{}` — no behaviour change for users who don't set it. +- Invalid entries (non-string keys or values) are dropped silently at load time, not at spawn time. + +### Related decisions + +- [ADR 008 — Config](008.webtty.config.md) — config file format and load strategy this ADR extends +- [ADR 028 — Session Base Directory](028.session.base-dir.md) — `cwd` threaded through the same spawn chain; `env` follows the same pattern +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — execute endpoint that also receives injected env diff --git a/docs/specs/cli.md b/docs/specs/cli.md index f28bd3a..f78d333 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -59,3 +59,4 @@ The command exits when the editor exits. | Help and config | `webtty help` — show all commands; `webtty config` — open config in `$VISUAL`/`$EDITOR`/`vi` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help formatting | Description first, all-caps headings, aligned params, frequency-ordered commands, annotated usage lines | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Stop on last rm | `webtty rm` auto-stops the server when the last session is removed | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Run | `webtty run ` — start PTY without browser (auto-starts server + session); `webtty run [args...]` — run command headlessly, stream stdout/stderr, forward exit code | [ADR 029](../adrs/029.cli.exec.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 7a43152..d1ecca6 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -210,3 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | +| Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ✅ | diff --git a/docs/specs/server.pipe.md b/docs/specs/headless-execute.md similarity index 90% rename from docs/specs/server.pipe.md rename to docs/specs/headless-execute.md index 1414077..be2031f 100644 --- a/docs/specs/server.pipe.md +++ b/docs/specs/headless-execute.md @@ -63,7 +63,7 @@ Any browser-based tool (extension, localhost web app) that needs to call a local 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). +For API reference and design rationale see [ADR 026](../adrs/026.server.headless-execute.md). --- @@ -71,4 +71,5 @@ For API reference and design rationale see [ADR 026](../adrs/026.server.pipe.md) | 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) | ☐ | +| 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.headless-execute.md) | ✓ | +| Execute resilience & structured errors | Process-level crash guards; `child.on('error')` handler; exit line carries `error`+`code` on spawn failure so agents can branch without parsing stderr text | [ADR 027](../adrs/027.server.execute-resilience.md) | ✓ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index d3059fa..9d5abbc 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -60,7 +60,7 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/sessions` | List all sessions; connection refused = server not running | -| `POST` | `/api/sessions` | Create session; body `{ id? }`; auto-generates ID if omitted; `409` if ID exists; validates ID rules | +| `POST` | `/api/sessions` | Create session; body `{ id?, baseDir? }`; auto-generates ID if omitted; `409` if ID exists; validates ID rules; `baseDir` must be an absolute path that exists on disk (default: `homedir()`) | | `GET` | `/api/sessions/:id` | Get single session; `404` if absent | | `PATCH` | `/api/sessions/:id` | Rename session; body `{ id }`; `409` if new ID exists; `404` if session absent; validates ID rules | | `DELETE` | `/api/sessions/:id` | Kill PTY, close connected WebSocket, remove session; `204` with `X-Sessions-Remaining: ` header, or `404` | @@ -81,3 +81,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ | +| Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir ` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ✅ | diff --git a/package.json b/package.json index d4f24ad..68f0b19 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "build": "bun run scripts/build.ts", "server": "bun run dist/server/index.js", "server:node": "node dist/server/index.js", - "webtty": "bun -- dist/cli/index.js", + "webtty:prod": "bun -- dist/cli/index.js", + "webtty": "bun run src/cli/index.ts", "prepack": "bun scripts/clean-pkg-scripts.ts strip", "postpack": "bun scripts/clean-pkg-scripts.ts restore" }, diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 4dded4f..10a84c6 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -739,4 +739,281 @@ describe('cli — unit (mocked http)', () => { onceSpy.mockRestore(); log.mockRestore(); }); + + // cmdRun — exec mode + + test('cmdRun with cmd when server not running exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: server is not running'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd when fetch throws exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: server is not running'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd 404 exits with session not found', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: session not found: sess'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd 409 exits with PTY not running message', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 409 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: PTY not running for session: sess'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd non-ok status exits with run failed', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: run failed (500)'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd streams stdout and exits 0', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'hello\n' })}\n`), + ); + controller.enqueue(enc.encode(`${JSON.stringify({ exit: 0 })}\n`)); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit0'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hello'])).rejects.toThrow('exit0'); + expect(write).toHaveBeenCalledWith('hello\n'); + expect(exit).toHaveBeenCalledWith(0); + isRunning.mockRestore(); + write.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd exits 1 when stream ends without exit line', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'partial\n' })}\n`), + ); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'cmd')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: connection closed unexpectedly'); + expect(exit).toHaveBeenCalledWith(1); + isRunning.mockRestore(); + write.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd streams stderr and exits 1 with error message', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stderr', data: 'err output\n' })}\n`), + ); + controller.enqueue(enc.encode(`${JSON.stringify({ exit: 1, error: 'command failed' })}\n`)); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const writeErr = spyOn(process.stderr, 'write').mockImplementation(() => true); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit1'); + }); + await expect(cmds.cmdRun('sess', 'bad-cmd')).rejects.toThrow('exit1'); + expect(writeErr).toHaveBeenCalledWith('err output\n'); + expect(err).toHaveBeenCalledWith('webtty: command failed'); + expect(exit).toHaveBeenCalledWith(1); + isRunning.mockRestore(); + writeErr.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + // cmdRun — warm-up mode (uses global WebSocket) + + function makeMockWebSocket(behavior: 'open' | 'error' | 'close-without-open') { + return class MockWebSocket { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((evt: CloseEvent) => void) | null = null; + constructor(_url: string) { + queueMicrotask(() => { + if (behavior === 'open') { + this.onopen?.(); + } else if (behavior === 'error') { + this.onerror?.(); + } else { + this.onclose?.({ code: 1006 } as CloseEvent); + } + }); + } + close(_code?: number) { + queueMicrotask(() => this.onclose?.({ code: 1000 } as CloseEvent)); + } + } as unknown as typeof WebSocket; + } + + test('cmdRun warm-up when server running and session exists prints url', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('warmup-sess'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess')); + isRunning.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when server not running starts it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('warmup-sess2'); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess2')); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when session not found creates it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + let callCount = 0; + global.fetch = mock(async () => { + callCount++; + if (callCount === 1) return new Response(null, { status: 404 }); // session check + return new Response(JSON.stringify({ id: 'new-sess' }), { status: 200 }); // create session + }) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('new-sess'); + expect(callCount).toBe(2); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/new-sess')); + isRunning.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when session creation fails exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + let callCount = 0; + global.fetch = mock(async () => { + callCount++; + if (callCount === 1) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('fail-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: bad'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun warm-up when WebSocket errors exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('error'); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('ws-err-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when WebSocket closes before open exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('close-without-open'); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('ws-close-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + global.WebSocket = origWS; + }); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 96d5abe..d2c7adf 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -20,8 +20,9 @@ export function toBrowserHost(host: string): string { * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. * * @param id - The session ID to open (default: `'main'`). + * @param baseDir - Working directory for the PTY shell (optional). */ -export async function cmdGo(id = 'main'): Promise { +export async function cmdGo(id = 'main', baseDir?: string): Promise { if (!(await isServerRunning())) { await startServer(); } @@ -34,7 +35,7 @@ export async function cmdGo(id = 'main'): Promise { const res = await fetch(`${getBaseUrl()}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), + body: JSON.stringify({ id, ...(baseDir ? { baseDir } : {}) }), }); if (!res.ok) { const body = (await res.json()) as { error?: string }; @@ -216,6 +217,106 @@ export function bytesToDisplay(buf: Buffer): string { .join(' '); } +export async function cmdRun(id: string, cmd?: string, args: string[] = []): Promise { + if (!cmd) { + // Warm-up mode: start server + session + PTY, no browser + if (!(await isServerRunning())) { + await startServer(); + } + + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); + if (check.status !== 200) { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); + process.exit(1); + } + } + + const wsUrl = `ws://127.0.0.1:${getPort()}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let opened = false; + ws.onopen = () => { + opened = true; + ws.close(1000); + resolve(); + }; + ws.onerror = () => reject(new Error('failed to connect to PTY')); + ws.onclose = (evt) => { + if (!opened) reject(new Error(`failed to start PTY (code ${(evt as CloseEvent).code})`)); + }; + }).catch((err: Error) => { + console.error(`webtty: failed to start PTY: ${err.message}`); + process.exit(1); + }); + + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${id}`; + console.log(url); + return; + } + + // Exec mode: headless command execution + if (!(await isServerRunning())) { + console.error('webtty: server is not running'); + process.exit(1); + } + + let res: Response; + try { + res = await fetch(`${getBaseUrl()}/s/${encodeURIComponent(id)}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd, args }), + }); + } catch { + console.error('webtty: server is not running'); + process.exit(1); + } + + if (res.status === 404) { + console.error(`webtty: session not found: ${id}`); + process.exit(1); + } + if (res.status === 409) { + console.error(`webtty: PTY not running for session: ${id}`); + process.exit(1); + } + if (!res.ok || !res.body) { + console.error(`webtty: run failed (${res.status})`); + process.exit(1); + } + + 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) continue; + const msg = JSON.parse(line) as Record; + if (msg.stream === 'stdout') process.stdout.write(String(msg.data)); + else if (msg.stream === 'stderr') process.stderr.write(String(msg.data)); + else if ('exit' in msg) { + if (msg.error) console.error(`webtty: ${msg.error}`); + process.exit((msg.exit as number) ?? 1); + } + } + } + console.error('webtty: connection closed unexpectedly'); + process.exit(1); +} + export function cmdKey(): void { if (!process.stdin.isTTY) { console.error('webtty key: requires an interactive terminal'); diff --git a/src/cli/index.ts b/src/cli/index.ts index 56002ef..65e3e3a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { cmdConfig, cmdGo, @@ -5,11 +6,12 @@ import { cmdList, cmdRemove, cmdRename, + cmdRun, cmdStart, cmdStop, } from './commands'; -const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); +const GO_ALIASES = new Set(['go', 'a', 'attach', 'open']); function printHelp(): void { const indent = ' '; @@ -29,6 +31,7 @@ function printHelp(): void { row('ls [id]', 'List all sessions, or filter by id substring'), row('rm ', 'Destroy a session'), row('mv ', 'Rename a session'), + row('run [cmd]', 'Start PTY (no browser) or run a command in a session'), row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), @@ -38,12 +41,19 @@ function printHelp(): void { ); } -const [, , cmd, ...rest] = process.argv; +const allArgs = process.argv.slice(2); +const dirFlagIdx = allArgs.indexOf('--dir'); +let cliBaseDir: string | undefined; +if (dirFlagIdx !== -1) { + cliBaseDir = path.resolve(allArgs[dirFlagIdx + 1] ?? '.'); + allArgs.splice(dirFlagIdx, 2); +} +const [cmd, ...rest] = allArgs; if (!cmd) { - await cmdGo(); + await cmdGo('main', cliBaseDir); } else if (GO_ALIASES.has(cmd)) { - await cmdGo(rest[0]); + await cmdGo(rest[0] ?? 'main', cliBaseDir); } else { switch (cmd) { case 'ls': @@ -68,6 +78,15 @@ if (!cmd) { case 'config': cmdConfig(); break; + case 'run': { + const [id, runCmd, ...runArgs] = rest; + if (!id) { + console.error('usage: webtty run [cmd [args...]]'); + process.exit(1); + } + await cmdRun(id, runCmd, runArgs); + break; + } case 'key': cmdKey(); break; diff --git a/src/config.ts b/src/config.ts index 1464601..6c71dfb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -97,6 +97,8 @@ export interface Config { theme: Theme; /** Custom key-to-sequence bindings. Merged with built-in defaults by `(key, mods)` identity. */ keyboardBindings: KeyboardBinding[]; + /** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ + env: Record; } /** Returns the webtty config directory: `~/.config/webtty`. */ @@ -164,6 +166,7 @@ export const DEFAULT_CONFIG: Config = { logs: false, theme: DEFAULT_THEME, keyboardBindings: DEFAULT_KEYBOARD_BINDINGS, + env: {}, }; function bindingKey(b: KeyboardBinding): string { @@ -283,6 +286,15 @@ export function loadConfig(): Config { p.keyboardBindings.filter(isValidBinding).map(normalizeBinding), ), }), + ...(p.env && + typeof p.env === 'object' && + !Array.isArray(p.env) && { + env: Object.fromEntries( + Object.entries(p.env as Record).filter( + ([k, v]) => typeof k === 'string' && typeof v === 'string', + ) as [string, string][], + ), + }), }; } diff --git a/src/pty/bun.ts b/src/pty/bun.ts index cf5ced7..d9f644a 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -1,4 +1,3 @@ -import { homedir } from 'node:os'; import type { PtyProcess } from './types'; /** @@ -9,6 +8,7 @@ import type { PtyProcess } from './types'; * @param rows - Terminal height in rows. * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawn( @@ -17,6 +17,8 @@ export function spawn( rows: number, term: string, colorTerm: string, + cwd: string, + env: Record, ): PtyProcess { let onDataCb: ((data: string) => void) | undefined; let onExitCb: ((e: { exitCode: number }) => void) | undefined; @@ -29,8 +31,8 @@ export function spawn( onDataCb?.(Buffer.from(data).toString('utf8')); }, }, - cwd: homedir(), - env: { ...process.env, TERM: term, COLORTERM: colorTerm }, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, }); proc.exited.then((exitCode) => { diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 929cf4c..2f1c899 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -28,7 +28,15 @@ const isBunOnWindows = describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); + const pty = spawnForSession( + 80, + 24, + TEST_SHELL, + 'xterm-256color', + 'truecolor', + process.cwd(), + {}, + ); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -41,7 +49,15 @@ describe('spawnForSession', () => { }); test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); + const pty = spawnForSession( + 80, + 24, + TEST_SHELL, + 'xterm-256color', + 'truecolor', + process.cwd(), + {}, + ); const received: string[] = []; pty.onData((data) => received.push(data)); diff --git a/src/pty/index.ts b/src/pty/index.ts index fc1ae5f..746583a 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -18,6 +18,7 @@ export const spawn = _spawn; * @param shell - Shell executable path (e.g., `/bin/bash`). * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawnForSession( @@ -26,6 +27,8 @@ export function spawnForSession( shell: string, term: string, colorTerm: string, + cwd: string, + env: Record, ) { - return _spawn(shell, cols, rows, term, colorTerm); + return _spawn(shell, cols, rows, term, colorTerm, cwd, env); } diff --git a/src/pty/node.ts b/src/pty/node.ts index 3639e8d..005af65 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -1,4 +1,3 @@ -import { homedir } from 'node:os'; import nodePty from '@lydell/node-pty'; import type { PtyProcess } from './types'; @@ -10,6 +9,7 @@ import type { PtyProcess } from './types'; * @param rows - Terminal height in rows. * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawn( @@ -18,6 +18,8 @@ export function spawn( rows: number, term: string, colorTerm: string, + cwd: string, + env: Record, ): PtyProcess { // On Windows, cmd.exe may have an AutoRun registry key that launches shell // enhancers (e.g. clink). These take over the ConPTY pipe and cause @@ -28,8 +30,8 @@ export function spawn( name: term, cols, rows, - cwd: homedir(), - env: { ...process.env, TERM: term, COLORTERM: colorTerm }, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, }); return { diff --git a/src/server/index.ts b/src/server/index.ts index d3ab79a..45e7061 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -36,8 +36,21 @@ function shutdown() { }); } +process.on('uncaughtException', (err) => { + console.error('[webtty] uncaughtException:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[webtty] unhandledRejection:', reason); +}); + const httpServer = http.createServer((req, res) => { - handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown); + handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown).catch((err) => { + console.error('[webtty] unhandled route error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); }); const wss = createWebSocketServer(httpServer); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index a98ccfa..2e74f65 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -144,6 +144,28 @@ describe('server — routes', () => { expect(res.status).toBe(400); }); + test('POST /api/sessions returns 400 for non-existent baseDir', async () => { + const res = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'basedir-missing', baseDir: '/no/such/path' }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('does not exist'); + }); + + test('POST /api/sessions returns 400 when baseDir is a file not a directory', async () => { + const res = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'basedir-file', baseDir: import.meta.path }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('not a directory'); + }); + test('GET /api/sessions/:id returns session', async () => { const res = await fetch(`${baseUrl}/api/sessions/test-session`); expect(res.status).toBe(200); @@ -323,12 +345,47 @@ describe('server — routes', () => { .split('\n') .filter(Boolean) .map((l) => JSON.parse(l) as Record); - const stdoutLine = lines.find((l) => l['stream'] === 'stdout'); + const stdoutLine = lines.find((l) => l.stream === 'stdout'); expect(stdoutLine).toBeDefined(); - expect(String(stdoutLine?.['data'])).toContain('hello-execute'); + 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 /s/:id/execute streams stderr and exit:1 for unknown command', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-enoent' }), + }); + const wsUrl = baseUrl.replace(/^http/, 'ws'); + const ws = new WebSocket(`${wsUrl}/ws/execute-enoent/pty?cols=80&rows=24`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error('WS error')); + setTimeout(() => reject(new Error('WS timeout')), 5000); + }); + const res = await fetch(`${baseUrl}/s/execute-enoent/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: '__no_such_command__', args: [] }), + }); + ws.close(); + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as Record); + const stderrLine = lines.find((l) => l.stream === 'stderr'); + expect(stderrLine).toBeDefined(); const exitLine = lines.find((l) => 'exit' in l); expect(exitLine).toBeDefined(); - expect(exitLine?.['exit']).toBe(0); + expect(exitLine?.exit).toBe(1); + expect(exitLine?.code).toBe('ENOENT'); + expect(typeof exitLine?.error).toBe('string'); }); test('POST /api/server/stop returns 200 and stops server', async () => { diff --git a/src/server/routes.ts b/src/server/routes.ts index 60c6b1b..b531fa6 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process'; +import fs from 'node:fs'; import type http from 'node:http'; +import { homedir } from 'node:os'; import path from 'node:path'; import { loadConfig } from '../config'; import { @@ -131,9 +133,9 @@ export async function handleRequest( } if (req.method === 'POST') { - let body: { id?: string }; + let body: { id?: string; baseDir?: string }; try { - body = (await readJson(req)) as { id?: string }; + body = (await readJson(req)) as { id?: string; baseDir?: string }; } catch (err) { const status = (err as { status?: number }).status === 413 ? 413 : 400; res.writeHead(status); @@ -152,7 +154,23 @@ export async function handleRequest( res.end(JSON.stringify({ error: `session already exists: ${id}` })); return; } - const session = createSession(id); + const baseDir = body.baseDir ?? homedir(); + if (!path.isAbsolute(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'baseDir must be an absolute path' })); + return; + } + if (!fs.existsSync(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); + return; + } + if (!fs.statSync(baseDir).isDirectory()) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir is not a directory: ${baseDir}` })); + return; + } + const session = createSession(id, baseDir); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(sessionToJson(session))); return; @@ -368,31 +386,50 @@ export async function handleRequest( res.end('invalid body'); return; } + let child: ReturnType; + try { + const execConfig = loadConfig(); + child = spawn(cmd, args as string[], { + env: { ...process.env, ...execConfig.env }, + cwd: session.baseDir, + }); + } catch (err) { + res.writeHead(500); + res.end(`spawn error: ${String(err)}`); + 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; + let done = false; + child.on('error', (err) => { + if (done) return; + done = true; + const msg = err.message; + const code = (err as NodeJS.ErrnoException).code; + res.write(`${JSON.stringify({ stream: 'stderr', data: `${msg}\n` })}\n`); + res.write(`${JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) })}\n`); + res.end(); + }); 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'); + 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'); + 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'); + if (done) return; + done = true; + res.write(`${JSON.stringify({ exit: code ?? 1 })}\n`); res.end(); }); req.on('close', () => { - if (!childExited) { - child.kill(); - } + if (!done) child.kill(); }); return; } diff --git a/src/server/session.ts b/src/server/session.ts index 919d4cc..a1524f7 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; @@ -7,6 +8,8 @@ export interface Session { id: string; /** Unix timestamp (ms) when the session was created. */ createdAt: number; + /** Working directory for the PTY shell. */ + baseDir: string; /** The underlying PTY process, or `null` if no shell has been spawned yet. */ pty: PtyProcess | null; /** All currently connected WebSocket clients for this session. */ @@ -55,12 +58,14 @@ export function generateId(): string { * Creates a new session, registers it in {@link sessionRegistry}, and returns it. * * @param id - The session ID. + * @param baseDir - Working directory for the PTY shell (default: `homedir()`). * @returns The newly created {@link Session}. */ -export function createSession(id: string): Session { +export function createSession(id: string, baseDir = homedir()): Session { const session: Session = { id, createdAt: Date.now(), + baseDir, pty: null, clients: new Set(), subscribers: new Set(), @@ -80,6 +85,7 @@ export function sessionToJson(s: Session) { return { id: s.id, createdAt: s.createdAt, + baseDir: s.baseDir, connected: s.clients.size > 0, pid: s.pty?.pid ?? null, }; diff --git a/src/server/websocket.ts b/src/server/websocket.ts index b1888b3..4ec37ae 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -190,7 +190,15 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer if (!session.pty) { const config = loadConfig(); - session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm); + session.pty = spawnForSession( + cols, + rows, + config.shell, + config.term, + config.colorTerm, + session.baseDir, + config.env, + ); session.pty.onData((data: string) => { session.scrollback = (session.scrollback + data).slice(-config.scrollback);