diff --git a/AGENTS.md b/AGENTS.md index 3f558ce3..6605758a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,14 @@ Remote: https://github.com/boringstack-xyz/tsforge matches nothing fails silently, so `grep` the new text before writing it into a commit message. Four commits in one session described fixes that were never in the file. +- **While the pane UI is up, only the renderer writes to the terminal.** A raw + write paints over the frame; this leaked three times (jsdom warnings, library + `console.error`, MCP servers' stderr). Show UI text through `echo`/`streamOut`, + never `process.stdout.write`. Never let a child process inherit stdout/stderr: + Bun.spawn's default stderr is `"inherit"`, so always set it (`"pipe"` and + drain, or `"ignore"`). `TerminalGuard` diverts in-process strays at runtime; + `tests/terminal-ownership.test.ts` rejects inheriting spawns; + `scripts/e2e-terminal-ownership-pty.py` drives the real UI with noisy parts. - **Scripts orchestrate; `src/` decides.** A file in `scripts/` is an entry point — parse args, call into `src/`, print. Any function it would be a bug to get wrong belongs in `src/`, where a test can import it. The graded score diff --git a/apps/docs/src/content/docs/integrations/mcp.mdx b/apps/docs/src/content/docs/integrations/mcp.mdx index 4a7d687d..64d91f9f 100644 --- a/apps/docs/src/content/docs/integrations/mcp.mdx +++ b/apps/docs/src/content/docs/integrations/mcp.mdx @@ -23,15 +23,15 @@ Add an `mcpServers` block to `tsforge.config.json` at your repo root. An entry i } ``` -| Field | Required | Notes | -| --- | --- | --- | -| `command` | yes (stdio) | Executable to spawn | -| `args` | no | Arguments for `command` | -| `env` | no | Extra env vars (merged over the parent env); `${VAR}` interpolated | -| `type` | no | `stdio` (default) or `http` | -| `url` | yes (http) | The server's MCP endpoint, e.g. `https://crm.example.com/mcp` | -| `headers` | no (http) | Headers sent on every request, e.g. `{ "Authorization": "Bearer …" }`; `${VAR}` interpolated | -| `timeoutMs` | no | Per-call timeout (default 30000) | +| Field | Required | Notes | +| ----------- | ----------- | -------------------------------------------------------------------------------------------- | +| `command` | yes (stdio) | Executable to spawn | +| `args` | no | Arguments for `command` | +| `env` | no | Extra env vars (merged over the parent env); `${VAR}` interpolated | +| `type` | no | `stdio` (default) or `http` | +| `url` | yes (http) | The server's MCP endpoint, e.g. `https://crm.example.com/mcp` | +| `headers` | no (http) | Headers sent on every request, e.g. `{ "Authorization": "Bearer …" }`; `${VAR}` interpolated | +| `timeoutMs` | no | Per-call timeout (default 30000) | ### Global servers (`~/.tsforge/models.json`) @@ -40,10 +40,16 @@ An `mcpServers` block also works in the global model registry, `~/.tsforge/model ```json title="~/.tsforge/models.json" { "active": "...", - "models": { "...": { } }, + "models": { "...": {} }, "mcpServers": { - "linear": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] }, - "sentry": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.sentry.dev/sse"] } + "linear": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] + }, + "sentry": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.sentry.dev/sse"] + } } } ``` @@ -85,7 +91,10 @@ Hosted servers that sign you in through a browser (OAuth), like Linear's, Notion ```json { "mcpServers": { - "linear": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] } + "linear": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.linear.app/sse"] + } } } ``` @@ -96,6 +105,7 @@ MCP fits tsforge's deterministic, local-first design as an **opt-in context sour - MCP tools **never touch your editable scope** and **cannot satisfy or bypass the acceptance gate**. They add context, they don't certify "done". - Because they don't write the workspace, they remain available in **plan mode** (e.g. look up docs while planning). +- A stdio server's own logging (its stderr, e.g. `mcp-remote`'s `[Local→Remote] tools/call` lines) **never reaches your terminal**. It goes to the debug trace (`TSFORGE_TRACE`), and if the server dies, its last lines appear in the error message. - A server that fails to connect, crashes, or times out is **reported and skipped**. It can never block a session from starting or wedge the loop. A failed tool call comes back as an error string the model can react to. ## Limits today diff --git a/package.json b/package.json index 3b355cc1..91b4287c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "bun test packages", "check:bun": "bun packages/core/scripts/check-bun-version.ts", "e2e": "python3 scripts/e2e-iterm-tui.py && python3 scripts/e2e-iterm-plan-mode.py", - "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py && python3 scripts/e2e-inference-pathological-pty.py && python3 scripts/e2e-reviewfix-pty.py && python3 scripts/e2e-phaser-plan-pty.py && python3 scripts/e2e-harness-invariants-pty.py", + "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py && python3 scripts/e2e-inference-pathological-pty.py && python3 scripts/e2e-reviewfix-pty.py && python3 scripts/e2e-phaser-plan-pty.py && python3 scripts/e2e-harness-invariants-pty.py && python3 scripts/e2e-terminal-ownership-pty.py", "validate": "bun run check:bun && bun run typecheck && bun run lint && bun run format:check && bun run test && bun run e2e:pty", "rules:build": "bun packages/core/scripts/build-rules-md.ts", "rules:check": "bun run rules:build && git diff --exit-code packages/core/RULES.md", diff --git a/packages/core/ARCHITECTURE.md b/packages/core/ARCHITECTURE.md index 25f1e29d..acb2d76d 100644 --- a/packages/core/ARCHITECTURE.md +++ b/packages/core/ARCHITECTURE.md @@ -2,7 +2,7 @@ -Derived from `packages/core/src`: **32 subsystems**, **746 files**, **146k lines**, **154 cross-subsystem edges**. +Derived from `packages/core/src`: **32 subsystems**, **748 files**, **146k lines**, **154 cross-subsystem edges**. This page is the exhaustive record: every subsystem, every cross-subsystem edge, and @@ -16,7 +16,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `loop` | The drive-to-green engine: turns, tools, gate settling, steering, adapters | core | 177 | 49k | 8 | 24 | | `rule-packs` | The ESLint rule packs the gate enforces, grouped by stack | core | 191 | 23k | 3 | 3 | | `render` | Terminal UI — status bar, menus, wizards, markdown, diffs, spinners | core | 45 | 11k | 7 | 7 | -| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 28 | 10k | 2 | 20 | +| `cli` | Argument parsing, the interactive REPL, and per-mode wiring | core | 29 | 10k | 2 | 20 | | `agent` | Tool schemas, the model-as-agent wrapper, and the malformed-tool-call repair ladder | core | 11 | 5k | 9 | 9 | | `site-plugins` | Built-in site experts (Reddit…) that read a site's structured data through the Chrome bridge | optional | 34 | 4k | 1 | 4 | | `inference` | OpenAI-compatible provider: streaming, tool calls, reasoning, token usage | core | 12 | 3k | 10 | 4 | @@ -32,7 +32,7 @@ inventory, see the hand-drawn map on [Internals](/internals/). | `lib` | Shared primitives — fs, json, guards, scope globs, SSRF checks, clipboard | core | 21 | 2k | 26 | 0 | | `files` | Reading, creating, and hash-anchored editing of workspace files | core | 9 | 2k | 5 | 1 | | `policy` | Decides which actions are allowed in the current mode before they run | core | 5 | 2k | 5 | 3 | -| `mcp` | Model Context Protocol client that exposes external servers as tools | optional | 10 | 1k | 4 | 1 | +| `mcp` | Model Context Protocol client that exposes external servers as tools | optional | 11 | 2k | 4 | 1 | | `chrome-bridge` | Localhost WebSocket bridge to the tsforge Chrome extension — read-only research in the user's browser | optional | 9 | 1k | 4 | 1 | | `architecture` ⚠️ | Derives this map from source so the docs cannot drift from the code | optional | 8 | 1k | 0 | 0 | | `lsp` | TypeScript language service powering navigation and write-time diagnostics | optional | 3 | <1k | 5 | 0 | @@ -103,7 +103,7 @@ Async functions returning an exit code, declared under the CLI — the commands. | `main` | `cli.ts:801` | | `mapMode` | `cli.ts:520` | | `recipesMode` | `cli.ts:539` | -| `repl` | `cli/repl.ts:1174` | +| `repl` | `cli/repl.ts:1175` | | `reviewMode` | `cli.ts:203` | | `runOnce` | `cli.ts:105` | | `runTraceCommand` | `cli/repl-commands.ts:160` | diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 3e4cf09e..b6d979da 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -155,6 +155,7 @@ import { import { setMcpDiagnosticSink } from "../mcp"; import { trace } from "../lib/trace"; import { divertStrayConsole } from "./stray-console"; +import { TerminalGuard } from "./terminal-guard"; import { saveUserSetting } from "../config/user-settings"; import { setBootHeadline, @@ -2444,8 +2445,30 @@ export async function repl(args: ICliArgs): Promise { }; }; - // Pane console — the only interactive UI on a TTY. - const paneScreen = new PaneScreen(process.stdout); + // Pane console — the only interactive UI on a TTY. While it owns the screen, + // the TerminalGuard lets only the renderer (and the few deliberate raw + // writers below, via `terminalGuard.terminal`) reach the terminal: any other + // stdout write becomes transcript text, stderr and library console noise go + // to the debug trace. + const terminalGuard = new TerminalGuard({ + stdout: process.stdout, + stderr: process.stderr, + onStdout: (text) => { + paneScreen.appendMain(text); + }, + onStderr: (text) => { + trace("stderr", text.trimEnd()); + }, + }); + const paneScreen = new PaneScreen(terminalGuard.terminal); + + paneScreen.setActivityListener((active) => { + if (active) { + terminalGuard.engage(); + } else { + terminalGuard.release(); + } + }); const tuiKeybindings = resolveTuiKeybindings( parseProjectTuiKeybindings(delegationConfig), loadUserTuiKeybindings() @@ -2727,7 +2750,7 @@ export async function repl(args: ICliArgs): Promise { /** Raw terminal modes (bracketed paste, kitty keys) — never transcript. */ const writeTerm = (text: string): void => { - process.stdout.write(text); + terminalGuard.terminal.write(text); }; // --- live agent tree ------------------------------------------------------ @@ -3450,7 +3473,7 @@ export async function repl(args: ICliArgs): Promise { cwd: args.dir, suspend, resume, - out: (s) => process.stdout.write(s), + out: (s) => terminalGuard.terminal.write(s), columns: transcriptCols(), viewportRows: overlayBudget(), }).then(async (dest) => { @@ -3468,7 +3491,7 @@ export async function repl(args: ICliArgs): Promise { }, columns: transcriptCols(), viewportRows: overlayBudget(), - out: (s) => process.stdout.write(s), + out: (s) => terminalGuard.terminal.write(s), runRecipe: (recipe) => { if (recipe.gate !== undefined) { session.setGate(recipe.gate); diff --git a/packages/core/src/cli/terminal-guard.ts b/packages/core/src/cli/terminal-guard.ts new file mode 100644 index 00000000..de02bd5d --- /dev/null +++ b/packages/core/src/cli/terminal-guard.ts @@ -0,0 +1,239 @@ +import { format } from "node:util"; + +/** + * While the pane console owns the screen, the renderer is the ONLY thing that + * may write to the terminal. Anything else painting raw bytes lands on top of + * the frame: it scrolls the alternate screen, leaves stacked duplicate rows, + * and corrupts the input box. This has leaked three ways so far — jsdom's CSS + * warnings, library console.error, and stdio MCP servers' stderr (mcp-remote's + * `[pid] [Local→Remote] tools/call`) — each fixed at the site that showed it. + * The guard closes the class for in-process writers: + * + * - the renderer gets `terminal`, a handle bound to the real stdout; + * - while engaged, every other `process.stdout.write` becomes transcript text + * (`onStdout`) and every `process.stderr.write` goes to the debug trace + * (`onStderr`); + * - console.* too: Bun's console writes natively and never passes through + * process.stdout/stderr.write, so it is patched separately — log/info/dir + * to `onStdout`, error/warn/debug/trace to `onStderr`. + * + * Child processes write to the terminal's file descriptors directly, which no + * JS-level guard can see; tests/terminal-ownership.test.ts forbids spawning one + * that inherits stdout/stderr, and the PTY test drives the real UI with noisy + * components to catch anything that slips past both. + */ + +/** The writable surface the guard patches — `process.stdout` / `process.stderr`. */ +export interface IGuardableStream { + write: (...args: never[]) => boolean; + readonly isTTY?: boolean; + readonly rows?: number; + readonly columns?: number; +} + +/** What the renderer writes through: always the real terminal. */ +export interface IOwnedTerminal { + readonly isTTY?: boolean; + readonly rows?: number; + readonly columns?: number; + write(data: string): boolean; +} + +export interface ITerminalGuardOptions { + stdout: IGuardableStream; + stderr: IGuardableStream; + /** Stray stdout while engaged — shown in the transcript, not painted raw. */ + onStdout: (text: string) => void; + /** Stray stderr while engaged — to the debug trace. */ + onStderr: (text: string) => void; + /** The console to take over (default: the global one). */ + console?: IConsoleLike; +} + +type RawWrite = (chunk: unknown, ...rest: unknown[]) => boolean; + +function isRawWrite(v: unknown): v is RawWrite { + return typeof v === "function"; +} + +const decoder = new TextDecoder(); + +/** The console methods the guard takes over, and where each one goes. */ +const CONSOLE_ROUTES = { + log: "out", + info: "out", + dir: "out", + error: "err", + warn: "err", + debug: "err", + trace: "err", +} as const; + +type ConsoleMethod = keyof typeof CONSOLE_ROUTES; + +export type IConsoleLike = Record void>; + +/** A write() chunk as text (strings pass through; bytes are decoded). */ +export function chunkText(chunk: unknown): string { + if (typeof chunk === "string") { + return chunk; + } + + if (chunk instanceof Uint8Array) { + return decoder.decode(chunk); + } + + return String(chunk); +} + +/** write(chunk, encoding?, callback?) — honour the callback so callers that + * wait on it (streams, loggers) are not left hanging. */ +function callbackOf(rest: unknown[]): (() => void) | undefined { + const last = rest.at(-1); + + return typeof last === "function" + ? () => { + Reflect.apply(last, undefined, []); + } + : undefined; +} + +export class TerminalGuard { + /** The renderer's handle: the real stdout, engaged or not. */ + readonly terminal: IOwnedTerminal; + private readonly realOut: RawWrite; + private savedConsole: Partial = {}; + /** The exact write functions found at engage(), restored on release() — + * the originals themselves, not bound copies, so identity is preserved. */ + private savedWrites: { out: unknown; err: unknown } | null = null; + private engagedNow = false; + /** True while a diverted write is being handled — a sink that itself writes + * to a guarded stream must not loop back into the guard. */ + private diverting = false; + + constructor(private readonly opts: ITerminalGuardOptions) { + const out: unknown = opts.stdout.write; + + if (!isRawWrite(out) || !isRawWrite(opts.stderr.write)) { + throw new Error("terminal guard: streams have no write()"); + } + + this.realOut = out.bind(opts.stdout); + + const stdout = opts.stdout; + const realOut = this.realOut; + + this.terminal = { + get isTTY() { + return stdout.isTTY; + }, + get rows() { + return stdout.rows; + }, + get columns() { + return stdout.columns; + }, + write: (data: string): boolean => realOut(data), + }; + } + + get engaged(): boolean { + return this.engagedNow; + } + + /** Start diverting every non-renderer write. Idempotent. */ + engage(): void { + if (this.engagedNow) { + return; + } + + this.engagedNow = true; + this.savedWrites = { + out: this.opts.stdout.write, + err: this.opts.stderr.write, + }; + this.patch(this.opts.stdout, (text) => { + this.opts.onStdout(text); + }); + this.patch(this.opts.stderr, (text) => { + this.opts.onStderr(text); + }); + this.patchConsole(); + } + + /** Give the terminal back (the pane suspended or left). Idempotent. */ + release(): void { + if (!this.engagedNow) { + return; + } + + this.engagedNow = false; + + if (this.savedWrites !== null) { + Reflect.set(this.opts.stdout, "write", this.savedWrites.out); + Reflect.set(this.opts.stderr, "write", this.savedWrites.err); + this.savedWrites = null; + } + + const target = this.opts.console ?? console; + + for (const [method, original] of Object.entries(this.savedConsole)) { + Reflect.set(target, method, original); + } + + this.savedConsole = {}; + } + + /** Route console.* to the sinks while engaged; restore exactly what was + * there before (which may itself be an earlier diverter). */ + private patchConsole(): void { + const target = this.opts.console ?? console; + + for (const [method, route] of Object.entries(CONSOLE_ROUTES)) { + const original: unknown = Reflect.get(target, method); + + if (typeof original !== "function") { + continue; + } + + Reflect.set(this.savedConsole, method, original); + Reflect.set(target, method, (...args: unknown[]) => { + const text = `${format(...args)}\n`; + + this.divertText( + text, + route === "out" ? this.opts.onStdout : this.opts.onStderr + ); + }); + } + } + + /** Hand text to a sink once; a sink that writes back into the guard is + * dropped instead of recursing or painting. */ + private divertText(text: string, sink: (text: string) => void): void { + if (this.diverting) { + return; + } + + this.diverting = true; + + try { + sink(text); + } catch { + // a failing sink must not break the caller + } finally { + this.diverting = false; + } + } + + private patch(stream: IGuardableStream, sink: (text: string) => void): void { + const divert = (chunk: unknown, ...rest: unknown[]): boolean => { + this.divertText(chunkText(chunk), sink); + callbackOf(rest)?.(); + + return true; + }; + + Reflect.set(stream, "write", divert); + } +} diff --git a/packages/core/src/mcp/stderr-tail.ts b/packages/core/src/mcp/stderr-tail.ts new file mode 100644 index 00000000..014f43cb --- /dev/null +++ b/packages/core/src/mcp/stderr-tail.ts @@ -0,0 +1,94 @@ +import { trace } from "../lib/trace"; + +/** A terminal colour/cursor sequence (ESC [ … letter), built from the char + * code so the pattern holds no literal control character. */ +const ANSI_RE = new RegExp( + `${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, + "gu" +); + +/** Lines of a server's stderr kept for error messages. */ +const TAIL_LINES = 10; +/** Cap on one kept line — a server can print a whole stack or JSON blob. */ +const MAX_LINE_CHARS = 300; + +/** + * A stdio MCP server's stderr, captured instead of inherited. Inherited, it + * wrote straight onto the terminal over the interactive UI — `mcp-remote` (the + * bridge for Linear, Notion and Sentry) logs every call as + * `[pid] [Local→Remote] tools/call`. Each complete line goes to the debug + * trace (TSFORGE_TRACE); the last few are kept so a server that dies can say + * why in its error message. + */ +export class StderrTail { + private buffer = ""; + private readonly lines: string[] = []; + + constructor( + private readonly server: string, + private readonly sink: (scope: string, line: string) => void = trace + ) {} + + /** Feed a decoded chunk; complete lines are traced and kept. */ + push(chunk: string): void { + this.buffer += chunk; + + let newline = this.buffer.indexOf("\n"); + + while (newline !== -1) { + this.keep(this.buffer.slice(0, newline)); + this.buffer = this.buffer.slice(newline + 1); + newline = this.buffer.indexOf("\n"); + } + } + + /** Flush a trailing partial line (the stream ended without a newline). */ + end(): void { + if (this.buffer.length > 0) { + this.keep(this.buffer); + this.buffer = ""; + } + } + + /** The kept lines, oldest first, as one line for an error message. */ + text(): string { + return this.lines.join(" | "); + } + + private keep(raw: string): void { + // Drop colour codes (ESC [ … letter) and carriage returns: they are for a + // terminal, not a log. + const line = raw.replace(ANSI_RE, "").replace(/\r/gu, "").trim(); + + if (line.length === 0) { + return; + } + + this.sink(`mcp:${this.server}`, line); + this.lines.push( + line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line + ); + + if (this.lines.length > TAIL_LINES) { + this.lines.shift(); + } + } +} + +/** Drain a stderr stream into `tail` until it closes. Never rejects. */ +export async function drainStderr( + stream: ReadableStream, + tail: StderrTail +): Promise { + const decoder = new TextDecoder(); + + try { + for await (const chunk of stream) { + tail.push(decoder.decode(chunk, { stream: true })); + } + } catch { + // the process was killed mid-read; whatever arrived is already kept + } finally { + tail.end(); + } +} diff --git a/packages/core/src/mcp/stdio-transport.ts b/packages/core/src/mcp/stdio-transport.ts index c4380616..1334dd19 100644 --- a/packages/core/src/mcp/stdio-transport.ts +++ b/packages/core/src/mcp/stdio-transport.ts @@ -6,6 +6,7 @@ import { DEFAULT_TIMEOUT_MS, PROTOCOL_VERSION, } from "./tool-result"; +import { StderrTail, drainStderr } from "./stderr-tail"; import type { IMcpServerConfig, IMcpToolInfo, @@ -24,7 +25,9 @@ interface IPending { * by id, and enforces a per-call timeout so a hung server cannot stall the loop. */ export class StdioMcpTransport implements IMcpTransport { - private proc: Bun.Subprocess<"pipe", "pipe", "inherit"> | null = null; + private proc: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null; + /** The server's stderr — captured, never inherited (see StderrTail). */ + private readonly stderr: StderrTail; private readonly decoder = new LineDecoder(); private readonly pending = new Map(); private readonly timeoutMs: number; @@ -39,6 +42,7 @@ export class StdioMcpTransport implements IMcpTransport { private readonly config: IMcpServerConfig ) { this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.stderr = new StderrTail(name); } async connect(): Promise { @@ -51,20 +55,33 @@ export class StdioMcpTransport implements IMcpTransport { env: { ...process.env, ...(this.config.env ?? {}) }, stdin: "pipe", stdout: "pipe", - stderr: "inherit", + // Piped and drained, never inherited: an inherited stderr paints the + // server's own logging over the interactive UI. + stderr: "pipe", }); this.proc = proc; + + const stderrDone = drainStderr(proc.stderr, this.stderr); + // Fire-and-forget, but NOT swallowed: when the read loop ends — stdout closed // on a clean exit, or a read error on a crash — fail every in-flight request // immediately with a clear "connection closed", rather than letting each one // wait out its 30s timeout and surface a misleading "timed out". void this.readLoop(proc.stdout) .catch(() => undefined) + .then(async () => { + // A dying server's last stderr lines can land just after stdout closes; + // give them a moment so the error can say why. + await Promise.race([stderrDone, Bun.sleep(100)]); + }) .finally(() => { this.connectionClosed = true; + + const why = this.stderr.text(); + this.failAllPending( - `MCP server '${this.name}' connection closed unexpectedly` + `MCP server '${this.name}' connection closed unexpectedly${why.length > 0 ? `: ${why}` : ""}` ); }); diff --git a/packages/core/src/render/frame/pane-screen.ts b/packages/core/src/render/frame/pane-screen.ts index 3927d354..456729a9 100644 --- a/packages/core/src/render/frame/pane-screen.ts +++ b/packages/core/src/render/frame/pane-screen.ts @@ -172,6 +172,14 @@ export class PaneScreen { return this.entered; } + /** Told when the pane takes the screen (true) and gives it back (false) — + * the REPL engages its TerminalGuard for exactly that window. */ + private activityListener: ((active: boolean) => void) | null = null; + + setActivityListener(listener: ((active: boolean) => void) | null): void { + this.activityListener = listener; + } + get focusState(): PaneFocus { return this.focus; } @@ -205,6 +213,7 @@ export class PaneScreen { this.entered = true; this.everEntered = true; this.prevLines = null; + this.activityListener?.(true); if (PERF_ENABLED) { armStallProbe(); @@ -246,6 +255,7 @@ export class PaneScreen { EXIT_ALT ); this.entered = false; + this.activityListener?.(false); this.prevLines = null; this.cursor.reset(); this.panelOffset = 0; diff --git a/packages/core/tests/fixtures/mock-mcp-server.ts b/packages/core/tests/fixtures/mock-mcp-server.ts index 5e12a8a4..074d2a24 100644 --- a/packages/core/tests/fixtures/mock-mcp-server.ts +++ b/packages/core/tests/fixtures/mock-mcp-server.ts @@ -19,6 +19,14 @@ function argText(params: unknown): string { } function handle(message: unknown): void { + // Chatty mode (opt-in via env): log every message to stderr the way + // mcp-remote does, to prove the transport never lets it reach the terminal. + if (process.env.MOCK_MCP_STDERR === "1" && isRecord(message)) { + process.stderr.write( + `[4242] [Local\u2192Remote] ${String(message.method)}\n` + ); + } + if (!isRecord(message) || typeof message.id !== "number") { return; // notification or junk — no response } @@ -57,6 +65,7 @@ function handle(message: unknown): void { // a server that dies mid-session. Exercises the transport's connection-closed // handling — the in-flight request must fail fast, not wait out its timeout. if (process.env.MOCK_MCP_CRASH_ON_CALL === "1") { + process.stderr.write("fatal: \u001b[31mOAuth token expired\u001b[0m\n"); process.exit(1); } diff --git a/packages/core/tests/mcp-stderr.test.ts b/packages/core/tests/mcp-stderr.test.ts new file mode 100644 index 00000000..24f2c00d --- /dev/null +++ b/packages/core/tests/mcp-stderr.test.ts @@ -0,0 +1,155 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { StderrTail, drainStderr } from "../src/mcp/stderr-tail"; +import { StdioMcpTransport } from "../src/mcp"; + +const SERVER = join(import.meta.dir, "fixtures", "mock-mcp-server.ts"); + +/** Runs a transport inside a CHILD process against the chatty mock server and + * returns what the child itself wrote to its stderr — in real use, that is the + * terminal under the interactive UI. */ +async function childStderr( + traceFile: string +): Promise<{ stderr: string; stdout: string }> { + const script = ` + import { StdioMcpTransport } from ${JSON.stringify(join(import.meta.dir, "..", "src", "mcp"))}; + const t = new StdioMcpTransport("chatty", { + type: "stdio", + command: process.execPath, + args: [${JSON.stringify(SERVER)}], + env: { MOCK_MCP_STDERR: "1" }, + timeoutMs: 5000, + }); + await t.connect(); + await t.listTools(); + console.log(await t.callTool("echo", { msg: "hi" })); + await t.close(); + `; + const dir = mkdtempSync(join(tmpdir(), "mcp-stderr-")); + const file = join(dir, "run.ts"); + + await Bun.write(file, script); + + const proc = Bun.spawn([process.execPath, file], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, TSFORGE_TRACE: traceFile }, + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + return { stdout, stderr }; +} + +test("a chatty stdio MCP server's stderr never reaches the terminal; it goes to the trace", async () => { + const trace = join(mkdtempSync(join(tmpdir(), "mcp-trace-")), "trace.log"); + const { stdout, stderr } = await childStderr(trace); + + expect(stdout.trim()).toBe("echo: hi"); + // the bug: mcp-remote's "[pid] [Local→Remote] tools/call" painted over the TUI + expect(stderr).not.toContain("Local→Remote"); + expect(stderr).toBe(""); + + const traced = readFileSync(trace, "utf8"); + + expect(traced).toContain("[mcp:chatty] [4242] [Local→Remote] initialize"); + expect(traced).toContain("[mcp:chatty] [4242] [Local→Remote] tools/call"); +}, 20_000); + +test("a server that dies explains why: its last stderr lands in the error", async () => { + const t = new StdioMcpTransport("mock", { + type: "stdio", + command: process.execPath, + args: [SERVER], + env: { MOCK_MCP_CRASH_ON_CALL: "1" }, + timeoutMs: 5000, + }); + + await t.connect(); + await expect(t.callTool("echo", { msg: "x" })).rejects.toThrow( + "connection closed unexpectedly: fatal: OAuth token expired" + ); + await t.close(); +}, 20_000); + +test("stderr that arrives just AFTER stdout closes still makes it into the error", async () => { + // A shell server, because only a real fd close ends the stream early: answer + // initialize (id 0), swallow the initialized notification, then on the call + // CLOSE STDOUT FIRST and explain on stderr a moment later — the order a dying + // bridge often uses. + const script = [ + "read -r _", + `printf '%s\\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}'`, + "read -r _", + "read -r _", + "exec 1>&-", + "sleep 0.05", + "echo 'fatal: upstream closed the session' >&2", + "exit 1", + ].join("\n"); + const t = new StdioMcpTransport("mock", { + type: "stdio", + command: "sh", + args: ["-c", script], + timeoutMs: 5000, + }); + + await t.connect(); + await expect(t.callTool("echo", { msg: "x" })).rejects.toThrow( + "connection closed unexpectedly: fatal: upstream closed the session" + ); + await t.close(); +}, 20_000); + +test("StderrTail: traces complete lines, strips colour, keeps the last 10", () => { + const seen: string[] = []; + const tail = new StderrTail("s", (scope, line) => + seen.push(`${scope} ${line}`) + ); + + tail.push("\u001b[33mwarn\u001b[0m: a\r\nb"); + expect(seen).toEqual(["mcp:s warn: a"]); // "b" is not complete yet + tail.push("\n\n \n"); + expect(seen).toEqual(["mcp:s warn: a", "mcp:s b"]); // blank lines dropped + + for (let i = 0; i < 12; i += 1) { + tail.push(`line ${String(i)}\n`); + } + + expect(tail.text().split(" | ")).toHaveLength(10); + expect(tail.text()).toStartWith("line 2 | "); + expect(tail.text()).toEndWith("line 11"); +}); + +test("StderrTail: a trailing partial line is flushed on end, long lines capped", () => { + const tail = new StderrTail("s", () => {}); + + tail.push(`${"x".repeat(500)}\npartial`); + tail.end(); + + const [long, last] = tail.text().split(" | "); + + expect(long).toHaveLength(301); + expect(long?.endsWith("…")).toBe(true); + expect(last).toBe("partial"); +}); + +test("drainStderr reads a stream to the end and flushes", async () => { + const tail = new StderrTail("s", () => {}); + const enc = new TextEncoder(); + const stream = new ReadableStream({ + start(c) { + c.enqueue(enc.encode("one\ntw")); + c.enqueue(enc.encode("o")); + c.close(); + }, + }); + + await drainStderr(stream, tail); + expect(tail.text()).toBe("one | two"); +}); diff --git a/packages/core/tests/terminal-guard.test.ts b/packages/core/tests/terminal-guard.test.ts new file mode 100644 index 00000000..6eb5a8ab --- /dev/null +++ b/packages/core/tests/terminal-guard.test.ts @@ -0,0 +1,209 @@ +import { test, expect } from "bun:test"; +import { + TerminalGuard, + chunkText, + type IConsoleLike, + type IGuardableStream, +} from "../src/cli/terminal-guard"; + +/** A stand-in for process.stdout / process.stderr that records raw writes. */ +function fakeStream(): IGuardableStream & { raw: string[] } { + const raw: string[] = []; + + return { + raw, + isTTY: true, + rows: 40, + columns: 120, + write(...args: never[]): boolean { + raw.push(chunkText(args[0])); + + return true; + }, + }; +} + +function fakeConsole(): IConsoleLike & { raw: string[] } { + const raw: string[] = []; + + const rec = + (m: string) => + (...a: unknown[]): void => { + raw.push(`${m}:${a.join(" ")}`); + }; + + return { + raw, + log: rec("log"), + info: rec("info"), + dir: rec("dir"), + error: rec("error"), + warn: rec("warn"), + debug: rec("debug"), + trace: rec("trace"), + }; +} + +function setup(): { + guard: TerminalGuard; + out: ReturnType; + err: ReturnType; + con: ReturnType; + transcript: string[]; + traced: string[]; +} { + const out = fakeStream(); + const err = fakeStream(); + const con = fakeConsole(); + const transcript: string[] = []; + const traced: string[] = []; + const guard = new TerminalGuard({ + stdout: out, + stderr: err, + console: con, + onStdout: (t) => transcript.push(t), + onStderr: (t) => traced.push(t), + }); + + return { guard, out, err, con, transcript, traced }; +} + +/** Call a (possibly patched) stream's write the way a library would. */ +function write(stream: IGuardableStream, ...args: unknown[]): boolean { + return Reflect.apply(stream.write, stream, args) === true; +} + +test("released: every write reaches the terminal as before", () => { + const { out, err, con, transcript, traced } = setup(); + + write(out, "a"); + write(err, "b"); + con.log("c"); + expect(out.raw).toEqual(["a"]); + expect(err.raw).toEqual(["b"]); + expect(con.raw).toEqual(["log:c"]); + expect(transcript).toEqual([]); + expect(traced).toEqual([]); +}); + +test("engaged: stray stdout → transcript, stderr → trace, nothing painted", () => { + const { guard, out, err, transcript, traced } = setup(); + + guard.engage(); + expect(write(out, "gate: x\n")).toBe(true); + write(err, "[4242] [Local→Remote] tools/call\n"); + write(err, new TextEncoder().encode("bytes too\n")); + + expect(out.raw).toEqual([]); + expect(err.raw).toEqual([]); + expect(transcript).toEqual(["gate: x\n"]); + expect(traced).toEqual(["[4242] [Local→Remote] tools/call\n", "bytes too\n"]); +}); + +test("engaged: the renderer's handle still reaches the real terminal", () => { + const { guard, out } = setup(); + + guard.engage(); + guard.terminal.write("\u001b[2J frame"); + expect(out.raw).toEqual(["\u001b[2J frame"]); + expect(guard.terminal.isTTY).toBe(true); + expect(guard.terminal.rows).toBe(40); + expect(guard.terminal.columns).toBe(120); +}); + +test("engaged: console.* is taken over too (Bun's console bypasses the streams)", () => { + const { guard, con, transcript, traced } = setup(); + + guard.engage(); + con.log("hello %s", "there"); + con.info("i"); + con.error("Could not parse CSS stylesheet"); + con.warn("w"); + con.debug("d"); + + expect(con.raw).toEqual([]); + expect(transcript).toEqual(["hello there\n", "i\n"]); + expect(traced).toEqual(["Could not parse CSS stylesheet\n", "w\n", "d\n"]); +}); + +test("release restores the exact previous writers and console methods", () => { + const { guard, out, err, con } = setup(); + const before = { o: out.write, e: err.write, log: con.log, error: con.error }; + + guard.engage(); + expect(out.write).not.toBe(before.o); + guard.release(); + + expect(out.write).toBe(before.o); + expect(err.write).toBe(before.e); + expect(con.log).toBe(before.log); + expect(con.error).toBe(before.error); +}); + +test("engage / release are idempotent (double engage cannot lose the originals)", () => { + const { guard, out, con } = setup(); + const original = out.write; + const originalLog = con.log; + + guard.engage(); + guard.engage(); + guard.release(); + guard.release(); + expect(out.write).toBe(original); + expect(con.log).toBe(originalLog); + expect(guard.engaged).toBe(false); +}); + +test("a write callback is still called, so waiting callers don't hang", () => { + const { guard, out } = setup(); + let called = 0; + + guard.engage(); + write(out, "x", "utf8", () => { + called += 1; + }); + write(out, "y", () => { + called += 1; + }); + expect(called).toBe(2); +}); + +test("a sink that writes back to a guarded stream cannot loop or paint", () => { + const out = fakeStream(); + const err = fakeStream(); + let calls = 0; + const guard = new TerminalGuard({ + stdout: out, + stderr: err, + console: fakeConsole(), + onStdout: () => { + calls += 1; + write(err, "sink complains\n"); // e.g. trace() to stderr + }, + onStderr: () => { + calls += 1; + }, + }); + + guard.engage(); + write(out, "x"); + expect(calls).toBe(1); + expect(out.raw).toEqual([]); + expect(err.raw).toEqual([]); +}); + +test("a throwing sink never breaks the caller's write", () => { + const out = fakeStream(); + const guard = new TerminalGuard({ + stdout: out, + stderr: fakeStream(), + console: fakeConsole(), + onStdout: () => { + throw new Error("boom"); + }, + onStderr: () => {}, + }); + + guard.engage(); + expect(write(out, "x")).toBe(true); +}); diff --git a/packages/core/tests/terminal-ownership.test.ts b/packages/core/tests/terminal-ownership.test.ts new file mode 100644 index 00000000..fb5b6616 --- /dev/null +++ b/packages/core/tests/terminal-ownership.test.ts @@ -0,0 +1,205 @@ +/** + * Terminal ownership, enforced statically. While the pane console is up, only + * its renderer may write to the terminal. In-process writers are caught at + * runtime by the TerminalGuard (src/cli/terminal-guard.ts), but a CHILD process + * writes to the terminal's file descriptors directly — no JS guard sees that. + * The only defence is never letting a child inherit them. That is how stdio MCP + * servers' logging (`[pid] [Local→Remote] tools/call` from mcp-remote) painted + * over the UI: `stderr: "inherit"`. + * + * Bun.spawn's DEFAULT for stderr is "inherit", so an options object that simply + * omits it leaks too. Every Bun.spawn / Bun.spawnSync in src/ must therefore say + * where stderr goes, and nothing may inherit stdout/stderr. + */ +import { test, expect } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import ts from "typescript"; + +const SRC = join(import.meta.dir, "..", "src"); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + + if (statSync(path).isDirectory()) { + return sourceFiles(path); + } + + return /\.tsx?$/u.test(name) && !name.endsWith(".d.ts") ? [path] : []; + }); +} + +interface IFinding { + where: string; + problem: string; +} + +function propInit( + obj: ts.ObjectLiteralExpression, + name: string +): ts.Expression | undefined | null { + for (const p of obj.properties) { + if (ts.isShorthandPropertyAssignment(p) && p.name.text === name) { + return null; // present, value not statically known + } + + if ( + ts.isPropertyAssignment(p) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + p.name.text === name + ) { + return p.initializer; + } + } + + return undefined; +} + +function isInherit(e: ts.Expression | undefined | null): boolean { + if (e === undefined || e === null) { + return false; + } + + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) { + return e.text === "inherit"; + } + + // stdio: ["pipe", "inherit", …] — any inherited slot leaks + if (ts.isArrayLiteralExpression(e)) { + return e.elements.some((el) => isInherit(el)); + } + + return false; +} + +function hasSpread(obj: ts.ObjectLiteralExpression): boolean { + return obj.properties.some((p) => ts.isSpreadAssignment(p)); +} + +/** Findings for one source file. */ +export function scan(file: string, text: string): IFinding[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const findings: IFinding[] = []; + const where = (n: ts.Node): string => + `${relative(SRC, file)}:${String(sf.getLineAndCharacterOfPosition(n.getStart()).line + 1)}`; + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression.getText(sf); + const isBunSpawn = callee === "Bun.spawn" || callee === "Bun.spawnSync"; + const isNodeSpawn = + /^(?:\w+\.)?(?:spawn|spawnSync|execFile|execFileSync|fork)$/u.test( + callee + ); + + if (isBunSpawn) { + const [first, second] = node.arguments; + const opts = + first !== undefined && ts.isObjectLiteralExpression(first) + ? first + : second; + + if (opts === undefined || !ts.isObjectLiteralExpression(opts)) { + findings.push({ + where: where(node), + problem: `${callee} without an inline options object — pass { stdout, stderr } so it can be checked (Bun's default stderr is "inherit")`, + }); + } else { + const stderr = propInit(opts, "stderr"); + const stdout = propInit(opts, "stdout"); + const stdio = propInit(opts, "stdio"); + + if (stderr === undefined && stdio === undefined && !hasSpread(opts)) { + findings.push({ + where: where(node), + problem: `${callee} does not set stderr — Bun's default is "inherit", which paints over the UI. Use "pipe" (and drain it) or "ignore"`, + }); + } + + for (const [name, value] of [ + ["stderr", stderr], + ["stdout", stdout], + ["stdio", stdio], + ] as const) { + if (isInherit(value)) { + findings.push({ + where: where(node), + problem: `${callee} ${name}: "inherit" — a child writing to the terminal corrupts the UI; pipe it`, + }); + } + } + } + } + + if (isNodeSpawn) { + for (const arg of node.arguments) { + if ( + ts.isObjectLiteralExpression(arg) && + isInherit(propInit(arg, "stdio")) + ) { + findings.push({ + where: where(node), + problem: `${callee} stdio: "inherit" — pipe the child's output instead`, + }); + } + } + } + } + + ts.forEachChild(node, visit); + }; + + visit(sf); + + return findings; +} + +test("no child process in src/ can write to the terminal", () => { + const findings = sourceFiles(SRC).flatMap((f) => + scan(f, readFileSync(f, "utf8")) + ); + + expect(findings.map((f) => `${f.where} ${f.problem}`)).toEqual([]); +}); + +// ── the scanner itself (a guard that cannot fail proves nothing) ───────────── + +test("scanner flags the MCP bug and Bun's silent default", () => { + const leaks = [ + `Bun.spawn({ cmd: ["x"], stdout: "pipe", stderr: "inherit" });`, + `Bun.spawn(["x"], { stdout: "pipe" });`, + `Bun.spawnSync(["x"], { stdout: "inherit", stderr: "pipe" });`, + `Bun.spawn(["x"], { stdio: ["pipe", "inherit", "pipe"] });`, + `Bun.spawn(["x"], opts);`, + `Bun.spawn(["x"]);`, + `import { spawn } from "node:child_process"; spawn("x", [], { stdio: "inherit" });`, + `child_process.spawnSync("x", [], { stdio: ["ignore", "pipe", "inherit"] });`, + ]; + + for (const code of leaks) { + expect({ code, flagged: scan(join(SRC, "x.ts"), code).length > 0 }).toEqual( + { + code, + flagged: true, + } + ); + } +}); + +test("scanner passes the safe shapes", () => { + const safe = [ + `Bun.spawn({ cmd: ["x"], stdout: "pipe", stderr: "pipe" });`, + `Bun.spawn(["x"], { stdout: "ignore", stderr: "ignore" });`, + `Bun.spawn(["x"], { ...base, stdout: "pipe" });`, // spread: stderr set by base + `import { spawn } from "node:child_process"; spawn("x", [], { stdio: "pipe" });`, + `const s = "stderr: \\"inherit\\""; // strings in docs are not calls`, + ]; + + for (const code of safe) { + expect({ code, findings: scan(join(SRC, "x.ts"), code) }).toEqual({ + code, + findings: [], + }); + } +}); diff --git a/scripts/e2e-terminal-ownership-pty.py b/scripts/e2e-terminal-ownership-pty.py new file mode 100644 index 00000000..4cb524fb --- /dev/null +++ b/scripts/e2e-terminal-ownership-pty.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Real-PTY guard: while the pane console is up, nothing but the renderer may +reach the terminal. + +This leaked three times (jsdom CSS warnings, library console.error, and stdio +MCP servers' stderr — mcp-remote's `[pid] [Local→Remote] tools/call` painted +over the UI). The unit tests cover the TerminalGuard and the static spawn +check; this drives the REAL UI with a noisy component and reads every byte the +terminal received: + + - a stdio MCP server that logs every message on stderr, called by the model + mid-session (the exact shape of the bug); + - the harness's own `/gate` confirmation, which used to be a raw stdout + write under the pane and must now land in the transcript. + +Run: + python3 scripts/e2e-terminal-ownership-pty.py +""" +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) +from ptyharness import ( # noqa: E402 + Checker, + content_chunks, + drain, + read_until, + reap, + spawn_tsforge, + start_stub_server, + toolcall_chunks, + visible_text, +) + +ROWS, COLS = 40, 120 +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MOCK_SERVER = os.path.join(ROOT, "packages", "core", "tests", "fixtures", "mock-mcp-server.ts") +NOISE = ("Local→Remote", "Local\\u2192Remote", "[4242]") + + +def decide(messages): + """First turn: call the noisy MCP server's tool. After its result: finish.""" + last = messages[-1] if messages else {} + if last.get("role") == "tool": + return content_chunks("echo came back fine") + return toolcall_chunks("mcp__noisy__echo", {"msg": "ping"}) + + +def main(): + t = Checker() + srv, port = start_stub_server(decide) + work = tempfile.mkdtemp(prefix="tsforge-ownership-") + home = tempfile.mkdtemp(prefix="tsforge-ownership-home-") + trace = os.path.join(work, "trace.log") + + with open(os.path.join(work, "tsforge.config.json"), "w") as f: + json.dump( + { + "mcpServers": { + "noisy": { + "command": "bun", + "args": [MOCK_SERVER], + "env": {"MOCK_MCP_STDERR": "1"}, + } + } + }, + f, + ) + + pid, m = spawn_tsforge( + port, + extra_env={"TSFORGE_TRACE": trace}, + rows=ROWS, + cols=COLS, + cwd=work, + home=home, + ) + + try: + got, buf = read_until(m, lambda b: "TSFORGE" in b or "> " in b, 40) + t.check("REPL boots with the noisy MCP server configured", got) + + os.write(m, b"use the echo tool\r") + got, buf = read_until(m, lambda b: "echo came back fine" in b, 40, buf) + t.check("the model called the noisy server and finished", got) + + # "/" opens the command palette: pick /gate (it fills the input with + # "/gate "), then type the argument. + os.write(m, b"/") + _, buf = read_until(m, lambda b: "commands" in b, 10, buf) + os.write(m, b"gate\r") + buf = drain(m, 1.0, buf) + os.write(m, b"npm test\r") + buf = drain(m, 2.0, buf) + + # Every byte the terminal received, not just what is visible now: a + # leak that a later repaint covered up still counts. + leaked = [n for n in NOISE if n in buf] + t.check(f"no MCP server stderr reached the terminal (found: {leaked})", not leaked) + + traced = open(trace, encoding="utf-8").read() if os.path.exists(trace) else "" + t.check( + "the server's stderr went to the debug trace instead", + "[mcp:noisy]" in traced and "tools/call" in traced, + ) + + screen = visible_text(buf, rows=ROWS, cols=COLS) + t.check("/gate's confirmation is shown in the transcript", "gate: npm test" in screen) + # The old raw write was the bare line + newline straight onto the + # alternate screen (the PTY turns \n into \r\n); rendered through the + # pane it sits inside the frame, padded to the box edge. + t.check("/gate's confirmation was not written raw over the frame", not any(raw in buf for raw in ("gate: npm test\n", "gate: npm test\r\n"))) + t.check("the pane chrome survived", "TSFORGE" in screen) + finally: + reap(pid, m) + srv.shutdown() + + return t.finish() + + +if __name__ == "__main__": + sys.exit(main())