diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 707017acb..d68ae9b59 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; -import { createTerminal, type TerminalSession } from "./terminal.js"; +import { + createTerminal, + ensureNodePtySpawnHelperExecutableForCurrentPlatform, + type TerminalSession, +} from "./terminal.js"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; // Extract text from a single row function getRowText(state: ReturnType, rowIndex: number): string { @@ -40,12 +47,19 @@ async function waitForLines( describe("Terminal", () => { const sessions: TerminalSession[] = []; + const temporaryDirs: string[] = []; afterEach(async () => { for (const session of sessions) { session.kill(); } sessions.length = 0; + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } }); function trackSession(session: TerminalSession): TerminalSession { @@ -54,6 +68,24 @@ describe("Terminal", () => { } describe("createTerminal", () => { + it("ensures darwin prebuild spawn-helper is executable", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); + temporaryDirs.push(packageRoot); + const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`); + mkdirSync(prebuildDir, { recursive: true }); + const helperPath = join(prebuildDir, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\necho helper\n"); + chmodSync(helperPath, 0o644); + + ensureNodePtySpawnHelperExecutableForCurrentPlatform({ + packageRoot, + platform: "darwin", + force: true, + }); + + expect(statSync(helperPath).mode & 0o111).toBe(0o111); + }); + it("creates a terminal session with an id, name, and cwd", async () => { const session = trackSession( await createTerminal({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index a88ace471..293976ae5 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,8 +1,13 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; +import { chmodSync, existsSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; const { Terminal } = xterm; +const require = createRequire(import.meta.url); +let nodePtySpawnHelperChecked = false; export interface Cell { char: string; @@ -83,6 +88,73 @@ export interface CreateTerminalOptions { name?: string; } +type EnsureNodePtySpawnHelperExecutableOptions = { + packageRoot?: string; + platform?: NodeJS.Platform; + arch?: string; + force?: boolean; +}; + +function resolveNodePtyPackageRoot(): string | null { + try { + const packageJsonPath = require.resolve("node-pty/package.json"); + return dirname(packageJsonPath); + } catch { + return null; + } +} + +function ensureExecutableBit(path: string): void { + if (!existsSync(path)) { + return; + } + const stat = statSync(path); + if (!stat.isFile()) { + return; + } + // node-pty 1.1.0 shipped darwin prebuild spawn-helper without execute bit. + if ((stat.mode & 0o111) === 0o111) { + return; + } + chmodSync(path, stat.mode | 0o111); +} + +export function ensureNodePtySpawnHelperExecutableForCurrentPlatform( + options: EnsureNodePtySpawnHelperExecutableOptions = {} +): void { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") { + return; + } + if (nodePtySpawnHelperChecked && !options.force) { + return; + } + + const packageRoot = options.packageRoot ?? resolveNodePtyPackageRoot(); + if (!packageRoot) { + return; + } + const arch = options.arch ?? process.arch; + + const candidates = [ + join(packageRoot, "build", "Release", "spawn-helper"), + join(packageRoot, "build", "Debug", "spawn-helper"), + join(packageRoot, "prebuilds", `darwin-${arch}`, "spawn-helper"), + ]; + + for (const candidate of candidates) { + try { + ensureExecutableBit(candidate); + } catch { + // best-effort hardening only + } + } + + if (!options.force) { + nodePtySpawnHelperChecked = true; + } +} + function extractCell(terminal: TerminalType, row: number, col: number): Cell { const buffer = terminal.buffer.active; const line = buffer.getLine(row); @@ -212,6 +284,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise