From 5fc9dc19020b1823181d92c24aef1d7cc5df9987 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sat, 25 Apr 2026 08:49:41 +0200 Subject: [PATCH 1/3] fix(contracts): raise terminal cols/rows ceiling for ultrawide displays The previous ceiling (400 cols / 200 rows) was hit by xterm's FitAddon on a 3440x1440 ultrawide (~411 cols at default density), causing the schema decode to reject the open/resize call before it reached the server. Raise to 1000 / 500 to comfortably cover ultrawide / 4K / 8K at small font sizes while still bounded enough to defend the PTY from malicious client input. Co-Authored-By: Claude Opus 4.7 --- packages/contracts/src/terminal.test.ts | 30 +++++++++++++++++++++++++ packages/contracts/src/terminal.ts | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 3feae674924..6ab692dad6b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -49,6 +49,36 @@ describe("TerminalOpenInput", () => { ).toBe(false); }); + it("accepts maximum cols and rows", () => { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 1000, + rows: 500, + }), + ).toBe(true); + }); + + it("rejects cols and rows above maximum", () => { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 1001, + rows: 24, + }), + ).toBe(false); + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 80, + rows: 501, + }), + ).toBe(false); + }); + it("defaults terminalId when missing", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 21bd74a0999..6ac1135db0d 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -5,10 +5,10 @@ export const DEFAULT_TERMINAL_ID = "default"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( - Schema.isLessThanOrEqualTo(400), + Schema.isLessThanOrEqualTo(1000), ); const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( - Schema.isLessThanOrEqualTo(200), + Schema.isLessThanOrEqualTo(500), ); const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); const TerminalEnvKeySchema = Schema.String.check( From ece1e87896cebf2ec2611baebfb68fe06e02e07b Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sat, 25 Apr 2026 08:49:48 +0200 Subject: [PATCH 2/3] fix(server): wrap Bun.spawn in Effect.try for typed PTY errors The Bun PTY adapter used Effect.sync, so any synchronous throw from Bun.spawn became an unhandled defect that bypassed the typed error union and reached the RPC client as a non-Error rejection (Effect's runPromise rejects with raw Cause.die payloads). Mirror NodePTY's Effect.try pattern so spawn failures surface as typed PtySpawnError. Co-Authored-By: Claude Opus 4.7 --- apps/server/src/terminal/Layers/BunPTY.ts | 45 +++++++++++++++-------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index f0aab813c7c..593fa4e5644 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,6 +1,11 @@ import { Effect, Layer } from "effect"; import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); @@ -99,22 +104,30 @@ export const layer = Layer.effect( } return { spawn: (input) => - Effect.sync(() => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); + Effect.try({ + try: () => { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return processHandle; + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle as PtyProcess; + }, + catch: (cause) => + new PtySpawnError({ + adapter: "bun-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }), } satisfies PtyAdapterShape; }), From ab83fa028506a202d314f916479cf187ca54466d Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sat, 25 Apr 2026 08:49:55 +0200 Subject: [PATCH 3/3] refactor(web): extract error-message helper for unknown rejections The instanceof Error ? err.message : fallback pattern silently drops non-Error rejections (e.g. RPC defects, plain objects with _tag), so the user sees a generic fallback instead of the underlying detail. Extract a shared extractErrorMessage helper that handles Error, objects with a message string, _tag-discriminated payloads, and string rejections, falling back only when nothing else is meaningful. Use the helper in the terminal drawer's open/write catch handlers. Co-Authored-By: Claude Opus 4.7 --- .../src/components/ThreadTerminalDrawer.tsx | 11 +--- apps/web/src/lib/errorMessage.test.ts | 61 +++++++++++++++++++ apps/web/src/lib/errorMessage.ts | 35 +++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/errorMessage.test.ts create mode 100644 apps/web/src/lib/errorMessage.ts diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..9a3d001f066 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -46,6 +46,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readEnvironmentApi } from "~/environmentApi"; +import { extractErrorMessage } from "~/lib/errorMessage"; import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; @@ -517,10 +518,7 @@ export function TerminalViewport({ void api.terminal .write({ threadId, terminalId, data }) .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), + writeSystemMessage(terminal, extractErrorMessage(err, "Terminal write failed")), ); }); @@ -702,10 +700,7 @@ export function TerminalViewport({ } } catch (err) { if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); + writeSystemMessage(terminal, extractErrorMessage(err, "Failed to open terminal")); } }; diff --git a/apps/web/src/lib/errorMessage.test.ts b/apps/web/src/lib/errorMessage.test.ts new file mode 100644 index 00000000000..de5379db2ed --- /dev/null +++ b/apps/web/src/lib/errorMessage.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { extractErrorMessage } from "./errorMessage"; + +describe("extractErrorMessage", () => { + const fallback = "fallback message"; + + it("returns the message of a regular Error", () => { + const err = new Error("boom"); + expect(extractErrorMessage(err, fallback)).toBe("boom"); + }); + + it("returns fallback when Error has empty message", () => { + const err = new Error(""); + expect(extractErrorMessage(err, fallback)).toBe(fallback); + }); + + it("returns fallback when Error message is whitespace", () => { + const err = new Error(" "); + expect(extractErrorMessage(err, fallback)).toBe(fallback); + }); + + it("extracts message from a non-Error object with a message string", () => { + const err = { message: "RPC defect: failed to spawn" }; + expect(extractErrorMessage(err, fallback)).toBe("RPC defect: failed to spawn"); + }); + + it("formats objects carrying a _tag with the serialized payload", () => { + const err = { _tag: "TerminalCwdError", cwd: "/tmp/missing", reason: "notFound" }; + expect(extractErrorMessage(err, fallback)).toContain("TerminalCwdError"); + expect(extractErrorMessage(err, fallback)).toContain("/tmp/missing"); + }); + + it("prefers object.message over _tag formatting", () => { + const err = { _tag: "Foo", message: "explicit message" }; + expect(extractErrorMessage(err, fallback)).toBe("explicit message"); + }); + + it("returns string rejections directly", () => { + expect(extractErrorMessage("a literal string defect", fallback)).toBe( + "a literal string defect", + ); + }); + + it("returns fallback for null", () => { + expect(extractErrorMessage(null, fallback)).toBe(fallback); + }); + + it("returns fallback for undefined", () => { + expect(extractErrorMessage(undefined, fallback)).toBe(fallback); + }); + + it("returns fallback for numeric rejections", () => { + expect(extractErrorMessage(42, fallback)).toBe(fallback); + }); + + it("survives circular objects without throwing", () => { + const err: { _tag: string; self?: unknown } = { _tag: "Circular" }; + err.self = err; + expect(extractErrorMessage(err, fallback)).toBe("Circular"); + }); +}); diff --git a/apps/web/src/lib/errorMessage.ts b/apps/web/src/lib/errorMessage.ts new file mode 100644 index 00000000000..e35781458e1 --- /dev/null +++ b/apps/web/src/lib/errorMessage.ts @@ -0,0 +1,35 @@ +export function extractErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + const message = error.message; + if (typeof message === "string" && message.trim().length > 0) { + return message; + } + } + + if (typeof error === "object" && error !== null) { + const messageCandidate = (error as { message?: unknown }).message; + if (typeof messageCandidate === "string" && messageCandidate.trim().length > 0) { + return messageCandidate; + } + + const tagCandidate = (error as { _tag?: unknown })._tag; + if (typeof tagCandidate === "string" && tagCandidate.length > 0) { + const serialized = safeStringify(error); + return serialized ? `${tagCandidate}: ${serialized}` : tagCandidate; + } + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return fallback; +} + +function safeStringify(value: unknown): string | null { + try { + return JSON.stringify(value); + } catch { + return null; + } +}