fix: Project action terminal lifecycle (#3287)

* fix(terminal): make command sessions own action lifecycle

* fix(ui): reconcile project action terminal state

* feat(ui): show running project actions in terminal tabs

* feat(ui): run project actions from linked worktrees

* fix(ui): guard project action reconciliation

* fix(ui): scope project action preview fallback

* fix(ui): default project actions to worktrees

* fix(ui): reveal project action terminals

* fix(ui): retain terminal output after snapshot replay

* fix(ui): restore running action terminals on revisit
This commit is contained in:
Matt Visnovsky
2026-09-05 12:04:36 +03:00
committed by GitHub
parent 58dfc789a2
commit 4e0eed717d
53 changed files with 5194 additions and 608 deletions
@@ -2,7 +2,7 @@
## Ownership
`runtime.js` owns terminal identity, PTY processes, status, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families and resolves the persisted shell ID without accepting command strings or arguments. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
`runtime.js` owns terminal identity, PTY processes, launch mode, session purpose, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families, resolves the persisted shell ID, and builds the per-shell argv for interactive versus command launches. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
## Protocol
@@ -19,21 +19,23 @@
HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path.
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. Listings include the effective launch mode and the normalized purpose, but never the command text. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them.
## PTY Lifecycle
- IDs are client-provided or generated with `randomUUID()`.
- Concurrent creates for one ID are single-flight only when working directory and shell preference match. Existing IDs cannot be reused for another working directory.
- Create defaults to interactive mode. Command mode requires a non-empty trimmed command no longer than the terminal input limit and launches the shell so the PTY exits when that command exits. Each session also carries a normalized purpose. Omitted purpose means `{ type: 'terminal' }`. Project actions use `{ type: 'project-action', actionId, executionId }`, and the server validates both IDs as non-empty bounded strings. Create responses and attach snapshots echo the effective mode and purpose.
- Concurrent creates for one ID are single-flight only when working directory, shell preference, login mode, launch mode, and session purpose match. Command-mode creates must also match the command text, unless the purpose is a project action that is already running for the same resolved `(cwd, actionId)` pair. In that case the runtime returns the existing session and its existing execution identity, even when another client requested a different session ID. Existing IDs cannot be reused for another working directory or another purpose.
- Dimensions are bounded to 1-1000 columns and 1-500 rows; input is capped at 64 KiB.
- A client may create before its renderer has mounted. It derives an initial size from the container and font metrics (falling back to 80x24 when unavailable), then sends a resize once Ghostty reports its final dimensions. This allows shell startup and renderer initialization to overlap.
- PTY children explicitly clear `NODE_CHANNEL_FD`; daemon IPC descriptors are host-private and invalid after PTY descriptor cleanup.
- PTY children also strip AppImage `ARGV0` (and other host-private shell vars such as `ELECTRON_RUN_AS_NODE`, `BASH_ENV`, `ENV`, `BASH_XTRACEFD`). An exported `ARGV0` makes zsh rewrite argv[0] for every external command, which breaks Python venv detection and other argv[0]/$0 consumers while leaving `/proc/self/exe` correct. On Linux, PTY spawn is wrapped with `env -u ARGV0` because `bun-pty` merges the native OS environ and would otherwise reintroduce `ARGV0` after a JS-only delete.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. Stale callbacks from replaced processes are ignored.
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
- Exited sessions remain attachable until explicit close or idle cleanup.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
## Security And Relay
+126 -14
View File
@@ -9,7 +9,7 @@ import {
} from './terminal-ws-protocol.js';
import { sanitizeTerminalHistoryChunk } from './history.js';
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js';
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js';
const MAX_SESSIONS = 20;
@@ -17,7 +17,66 @@ const MAX_HISTORY_BYTES = 512 * 1024;
const MAX_INPUT_CHARS = 65_536;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
const TERMINATION_GRACE_MS = 1000;
const INTERACTIVE_TERMINAL_MODE = 'interactive';
const COMMAND_TERMINAL_MODE = 'command';
const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' });
const MAX_PURPOSE_ID_CHARS = 128;
const OBJECT_TAG = '[object Object]';
const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
const isString = (value) => String(value) === value;
const isObjectRecord = (value) => value != null && !Array.isArray(value) && Object.prototype.toString.call(value) === OBJECT_TAG;
const normalizeCreateMode = ({ mode, command }) => {
const normalizedMode = mode == null ? INTERACTIVE_TERMINAL_MODE : mode;
if (normalizedMode !== INTERACTIVE_TERMINAL_MODE && normalizedMode !== COMMAND_TERMINAL_MODE) throw new Error('Invalid terminal mode');
if (normalizedMode === INTERACTIVE_TERMINAL_MODE) {
if (command != null) throw new Error('Interactive terminal create does not accept a command');
return { mode: INTERACTIVE_TERMINAL_MODE, command: null };
}
if (!isString(command) || !command.trim()) throw new Error('Terminal command is required');
const trimmedCommand = command.trim();
if (trimmedCommand.length > MAX_INPUT_CHARS) throw new Error('Terminal command exceeds the input limit');
return { mode: COMMAND_TERMINAL_MODE, command: trimmedCommand };
};
const normalizePurposeId = (value, errorMessage) => {
if (!isString(value) || !value.trim()) throw new Error(errorMessage);
const normalized = value.trim();
if (normalized.length > MAX_PURPOSE_ID_CHARS) throw new Error(errorMessage);
return normalized;
};
const normalizeTerminalPurpose = (value) => {
if (value == null) return TERMINAL_PURPOSE;
if (!isObjectRecord(value)) throw new Error('Invalid terminal purpose');
if (value.type === 'terminal') return TERMINAL_PURPOSE;
if (value.type !== 'project-action') throw new Error('Invalid terminal purpose');
return {
type: 'project-action',
actionId: normalizePurposeId(value.actionId, 'Terminal project action id is required'),
executionId: normalizePurposeId(value.executionId, 'Terminal execution id is required'),
};
};
const getSessionPurpose = (session) => session.purpose ?? TERMINAL_PURPOSE;
const isPurposeActionMatch = (left, right) => {
if (left.type !== right.type) return false;
return left.type !== 'project-action' || left.actionId === right.actionId;
};
const findRunningActionSession = (sessions, resolvedCwd, purpose, path) => {
if (purpose.type !== 'project-action') return null;
for (const session of sessions.values()) {
if (session.status !== 'running') continue;
if (path.resolve(session.cwd) !== resolvedCwd) continue;
const sessionPurpose = getSessionPurpose(session);
if (sessionPurpose.type === 'project-action' && sessionPurpose.actionId === purpose.actionId) return session;
}
return null;
};
const findPendingActionCreate = (pendingSessionCreates, resolvedCwd, purpose) => {
if (purpose.type !== 'project-action') return null;
for (const pending of pendingSessionCreates.values()) {
if (pending.cancelled || pending.cwd !== resolvedCwd) continue;
if (pending.purpose?.type === 'project-action' && pending.purpose.actionId === purpose.actionId) return pending;
}
return null;
};
const trimHistory = (history) => {
const bytes = Buffer.from(history);
if (bytes.byteLength <= MAX_HISTORY_BYTES) return history;
@@ -54,13 +113,11 @@ export function createTerminalRuntime({
return ptyProviderPromise;
};
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell }) => {
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell, mode, command }) => {
const provider = await getPtyProvider();
const resolvedShell = await shellResolver.resolve(shell);
let lastError = null;
for (const executable of resolvedShell.executables) {
const args = loginShell ? getTerminalShellLoginArgs(executable) : [];
if (!args) throw new Error(`Terminal shell "${resolvedShell.id}" does not support login mode`);
try {
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
// The daemon's IPC fd is closed inside the PTY. An explicit override is
@@ -70,9 +127,11 @@ export function createTerminalRuntime({
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
// bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux.
stripAppImageArgv0Leak(env);
const launch = resolveLinuxPtyLaunch(executable, args);
const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) };
return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
const launch = resolveLinuxPtyLaunch(shellLaunch.executable, shellLaunch.args);
const options = { name: 'xterm-256color', cwd, cols, rows, env };
if (process.platform === 'win32') options.useConpty = true;
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
} catch (error) { lastError = error; }
}
throw lastError ?? new Error('No executable shell found');
@@ -123,6 +182,7 @@ export function createTerminalRuntime({
const snapshot = (session) => ({
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
status: session.status, exitCode: session.exitCode, signal: session.signal,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
runtime, ptyBackend: session.backend,
});
@@ -192,48 +252,78 @@ export function createTerminalRuntime({
}
};
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell }, clear = true) => {
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell, mode = INTERACTIVE_TERMINAL_MODE, command = null, purpose = TERMINAL_PURPOSE }, clear = true) => {
await validateCwd(cwd);
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell });
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell, mode, command });
if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; }
session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process;
session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null;
session.mode = mode; session.command = mode === COMMAND_TERMINAL_MODE ? command : null;
session.purpose = purpose;
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
session.lastActivity = Date.now(); session.eventQueue.length = 0;
wire(session, spawned.process);
return spawned.process;
};
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false }) => {
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false, mode, command, purpose }) => {
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
const normalizedShell = normalizeTerminalShell(shell);
if (!normalizedShell) throw new Error('Invalid terminal shell');
const launchMode = normalizeCreateMode({ mode, command });
const normalizedPurpose = normalizeTerminalPurpose(purpose);
const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID();
if (id.length > 128) throw new Error('Invalid terminal session id');
const existing = sessions.get(id);
const resolvedCwd = path.resolve(cwd);
if (existing?.status === 'running') {
if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(getSessionPurpose(existing), normalizedPurpose)) throw new Error('Terminal session is already running with a different purpose');
if (normalizedPurpose.type === 'project-action') { applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; }
if ((existing.mode ?? INTERACTIVE_TERMINAL_MODE) !== launchMode.mode) throw new Error('Terminal session is already running with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && existing.command !== launchMode.command) throw new Error('Terminal session is already running with a different command');
applyAppearance(existing, { themeMode, terminalBackground, terminalForeground });
return existing;
}
const runningActionSession = findRunningActionSession(sessions, resolvedCwd, normalizedPurpose, path);
if (runningActionSession) {
applyAppearance(runningActionSession, { themeMode, terminalBackground, terminalForeground });
return runningActionSession;
}
const pending = pendingSessionCreates.get(id);
if (pending) {
if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
if (!isPurposeActionMatch(pending.purpose, normalizedPurpose)) throw new Error('Terminal session is already being created with a different purpose');
if (normalizedPurpose.type === 'project-action') return pending.promise;
if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell');
if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode');
if (pending.mode !== launchMode.mode) throw new Error('Terminal session is already being created with a different mode');
if (launchMode.mode === COMMAND_TERMINAL_MODE && pending.command !== launchMode.command) throw new Error('Terminal session is already being created with a different command');
const session = await pending.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
const pendingActionCreate = findPendingActionCreate(pendingSessionCreates, resolvedCwd, normalizedPurpose);
if (pendingActionCreate) {
const session = await pendingActionCreate.promise;
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session;
}
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
const creation = (async () => {
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell });
const ptyProcess = await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose });
if (pendingEntry.cancelled) {
session.process = null;
await terminateProcess(ptyProcess, true);
throw new Error('Terminal session was closed during creation');
}
sessions.set(id, session);
return session;
})();
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, promise: creation };
pendingEntry.promise = creation;
pendingSessionCreates.set(id, pendingEntry);
try { return await creation; }
finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); }
@@ -331,6 +421,8 @@ export function createTerminalRuntime({
cwd: session.cwd,
status: session.status,
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
res.json({ sessions: list });
@@ -349,7 +441,17 @@ export function createTerminalRuntime({
res.json({ touched });
});
app.post('/api/terminal/create', async (req, res) => {
try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); }
try {
const session = await createSession(req.body ?? {});
res.json({
sessionId: session.id,
cols: session.cols,
rows: session.rows,
status: session.status,
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
purpose: getSessionPurpose(session),
});
}
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); }
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
@@ -369,6 +471,7 @@ export function createTerminalRuntime({
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
if ((session.mode ?? INTERACTIVE_TERMINAL_MODE) === COMMAND_TERMINAL_MODE) return res.status(400).json({ error: 'Command-mode terminal sessions cannot be restarted' });
const cwd = req.body?.cwd ?? session.cwd;
const cols = req.body?.cols ?? session.cols;
const rows = req.body?.rows ?? session.rows;
@@ -398,7 +501,16 @@ export function createTerminalRuntime({
});
app.delete('/api/terminal/:sessionId', async (req, res) => {
const session = sessions.get(req.params.sessionId);
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
if (!session) {
const pending = pendingSessionCreates.get(req.params.sessionId);
if (!pending) return res.status(404).json({ error: 'Terminal session not found' });
pending.cancelled = true;
try { await pending.promise; }
catch (error) {
if (error?.message !== 'Terminal session was closed during creation') throw error;
}
return res.json({ success: true });
}
sessions.delete(session.id);
closeAttachments(session.id, 'CLOSED', 'Terminal closed');
await terminateProcess(session.process);
+480 -26
View File
@@ -3,7 +3,6 @@ import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import express from 'express';
import { WebSocket } from 'ws';
import { createTerminalRuntime } from './runtime.js';
@@ -24,6 +23,98 @@ function createResponse() {
};
}
async function openTerminalSocket(socketUrl) {
const socket = new WebSocket(socketUrl);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createHttpTestApp() {
const routes = { GET: [], POST: [], DELETE: [] };
const app = (req, res) => {
const methodRoutes = routes[req.method] ?? [];
const url = new URL(req.url, 'http://127.0.0.1');
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const bodyText = Buffer.concat(chunks).toString('utf8');
const route = methodRoutes.find(({ pattern }) => pattern.test(url.pathname));
if (!route) {
res.statusCode = 404;
res.end('Not found');
return;
}
const match = route.pattern.exec(url.pathname);
const response = {
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.body = payload;
return this;
},
};
try {
await route.handler({
method: req.method,
url: req.url,
query: Object.fromEntries(url.searchParams.entries()),
params: route.params.reduce((acc, name, index) => ({ ...acc, [name]: match[index + 1] }), {}),
body: bodyText ? JSON.parse(bodyText) : {},
}, response);
} catch (error) {
response.status(500).json({ error: error?.message || 'Route failed' });
}
res.writeHead(response.statusCode, response.headers);
res.end(JSON.stringify(response.body));
});
};
const register = (method, route, handler) => {
const params = [];
const escaped = route.replace(/:([^/]+)/g, (_, name) => {
params.push(name);
return '([^/]+)';
});
routes[method].push({
pattern: new RegExp(`^${escaped}$`),
params,
handler,
});
};
app.get = (route, handler) => register('GET', route, handler);
app.post = (route, handler) => register('POST', route, handler);
app.delete = (route, handler) => register('DELETE', route, handler);
return app;
}
function createRuntime(server, overrides = {}) {
const app = overrides.app ?? {
post() {},
@@ -54,6 +145,7 @@ describe('terminal runtime', () => {
const createHarness = (overrides = {}) => {
const routes = { get: new Map(), post: new Map(), delete: new Map() };
const processes = [];
const spawnDeferred = overrides.spawnDeferred ?? null;
const app = {
post(route, handler) { routes.post.set(route, handler); },
get(route, handler) { routes.get.set(route, handler); },
@@ -61,7 +153,8 @@ describe('terminal runtime', () => {
};
const loadPtyProvider = async () => ({
backend: 'fake-pty',
spawn: (shell, args, options) => {
spawn: async (shell, args, options) => {
await spawnDeferred?.promise;
const dataHandlers = new Set();
const exitHandlers = new Set();
const process = {
@@ -150,7 +243,7 @@ describe('terminal runtime', () => {
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-1', cwd: '/repo', cols: 120, rows: 40, themeMode: 'light', terminalBackground: '#faf8f0', terminalForeground: '#1b1b1b' } }, response);
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running' });
expect(response.body).toEqual({ sessionId: 'term-1', cols: 120, rows: 40, status: 'running', mode: 'interactive', purpose: { type: 'terminal' } });
expect(harness.processes[0].options.cwd).toBe('/repo');
expect(harness.processes[0].options.env.COLORFGBG).toBe('0;15');
expect(harness.processes[0].options.env.NODE_CHANNEL_FD).toBe('');
@@ -192,7 +285,7 @@ describe('terminal runtime', () => {
const scoped = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped);
expect(scoped.body.sessions).toEqual([
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) },
{ sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number), mode: 'interactive', purpose: { type: 'terminal' } },
]);
const touch = createResponse();
@@ -466,8 +559,7 @@ describe('terminal runtime', () => {
});
it('runs snapshot-first attach, scoped I/O, replay, reconnect, and close over a real websocket', async () => {
const app = express();
app.use(express.json());
const app = createHttpTestApp();
const server = http.createServer(app);
const processes = [];
const loadPtyProvider = async () => ({
@@ -501,24 +593,6 @@ describe('terminal runtime', () => {
const socketUrl = `ws://127.0.0.1:${address.port}/api/terminal/ws`;
const sockets = [];
const open = async () => {
const socket = new WebSocket(socketUrl);
sockets.push(socket);
const messages = [];
socket.on('message', (raw) => messages.push(readTerminalWsControlFrame(raw)));
await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
const next = async (type, sessionId) => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const index = messages.findIndex((message) => message?.t === type && (!sessionId || message.s === sessionId));
if (index >= 0) return messages.splice(index, 1)[0];
await new Promise((resolve) => setTimeout(resolve, 2));
}
throw new Error(`Timed out waiting for ${type}`);
};
await next('hello');
return { socket, next, messages };
};
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -531,7 +605,8 @@ describe('terminal runtime', () => {
});
expect(secondCreated.status).toBe(200);
const first = await open();
const first = await openTerminalSocket(socketUrl);
sockets.push(first.socket);
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
first.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-second' }));
expect(await first.next('snapshot', 'term-live')).toMatchObject({ s: 'term-live', q: 0, history: '', status: 'running' });
@@ -559,7 +634,8 @@ describe('terminal runtime', () => {
expect(secondClosed.status).toBe(200);
first.socket.close();
const second = await open();
const second = await openTerminalSocket(socketUrl);
sockets.push(second.socket);
second.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'term-live' }));
expect(await second.next('snapshot')).toMatchObject({ s: 'term-live', q: 2, history: 'ok\r\n', status: 'running' });
processes[0].emitExit(7);
@@ -589,4 +665,382 @@ describe('terminal runtime', () => {
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('creates command-mode sessions and echoes the effective mode', async () => {
const harness = createHarness({
searchPathFor: (name) => name === 'bash' ? '/bin/bash' : '/bin/sh',
isExecutable: (candidate) => candidate === '/bin/bash' || candidate === '/bin/sh',
});
try {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready', shell: 'bash', loginShell: true } }, response);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ sessionId: 'term-command', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'terminal' } });
if (process.platform === 'linux') {
expect(harness.processes[0].shell).toMatch(/\/env$/);
expect(harness.processes[0].args).toEqual(['-u', 'ARGV0', '/bin/bash', '-l', '-i', '-c', 'printf ready']);
} else {
expect(harness.processes[0].args).toEqual(['-l', '-i', '-c', 'printf ready']);
}
} finally { await harness.runtime.shutdown(); }
});
it('rejects invalid terminal mode and command combinations', async () => {
const harness = createHarness();
try {
for (const [body, error] of [
[{ cwd: '/repo', mode: 'script' }, 'Invalid terminal mode'],
[{ cwd: '/repo', mode: 'command' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'command', command: ' ' }, 'Terminal command is required'],
[{ cwd: '/repo', mode: 'interactive', command: 'echo nope' }, 'Interactive terminal create does not accept a command'],
[{ cwd: '/repo', mode: 'command', command: 'x'.repeat(65_537) }, 'Terminal command exceeds the input limit'],
]) {
const response = createResponse();
await harness.routes.post.get('/api/terminal/create')({ body }, response);
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({ error });
}
expect(harness.processes).toHaveLength(0);
} finally { await harness.runtime.shutdown(); }
});
it('rejects same-id running creates when mode or command do not match', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({ body: { sessionId: 'term-shared', cwd: '/repo' } }, createResponse());
const modeMismatch = createResponse();
await create({ body: { sessionId: 'term-shared', cwd: '/repo', mode: 'command', command: 'printf ready' } }, modeMismatch);
expect(modeMismatch.statusCode).toBe(400);
expect(modeMismatch.body).toEqual({ error: 'Terminal session is already running with a different mode' });
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf ready' } }, createResponse());
const commandMismatch = createResponse();
await create({ body: { sessionId: 'term-command', cwd: '/repo', mode: 'command', command: 'printf other' } }, commandMismatch);
expect(commandMismatch.statusCode).toBe(400);
expect(commandMismatch.body).toEqual({ error: 'Terminal session is already running with a different command' });
} finally { await harness.runtime.shutdown(); }
});
it('rejects pending creates when command mode does not match the in-flight request', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
const conflictingMode = createResponse();
const conflictingCommand = createResponse();
const firstPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf ready' } }, first);
await Promise.resolve();
const secondPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo' } }, conflictingMode);
const thirdPromise = create({ body: { sessionId: 'term-pending', cwd: '/repo', mode: 'command', command: 'printf other' } }, conflictingCommand);
spawnDeferred.resolve();
await Promise.all([firstPromise, secondPromise, thirdPromise]);
expect(first.statusCode).toBe(200);
expect(conflictingMode.statusCode).toBe(400);
expect(conflictingMode.body).toEqual({ error: 'Terminal session is already being created with a different mode' });
expect(conflictingCommand.statusCode).toBe(400);
expect(conflictingCommand.body).toEqual({ error: 'Terminal session is already being created with a different command' });
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('validates purpose payloads and round-trips purpose through create, list, and snapshot without listing command text', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => ({
pid: 42,
write() {},
resize() {},
kill() {},
onData() { return { dispose() {} }; },
onExit() { return { dispose() {} }; },
}),
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
for (const [body, error] of [
[{ cwd: '/repo', purpose: 'terminal' }, 'Invalid terminal purpose'],
[{ cwd: '/repo', purpose: { type: 'project-action' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build' } }, 'Terminal execution id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: ' ', executionId: 'exec-1' } }, 'Terminal project action id is required'],
[{ cwd: '/repo', purpose: { type: 'project-action', actionId: 'build', executionId: ' ' } }, 'Terminal execution id is required'],
]) {
const response = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error });
}
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'printf ready',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}),
});
expect(created.status).toBe(200);
expect(await created.json()).toEqual({
sessionId: 'action-tab',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
const listed = await fetch(`${base}/api/terminal/sessions?cwd=%2Frepo`);
expect(listed.status).toBe(200);
expect(await listed.json()).toEqual({
sessions: [{
sessionId: 'action-tab',
cwd: '/repo',
status: 'running',
createdAt: expect.any(Number),
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
}],
});
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'action-tab' }));
expect(await socket.next('snapshot', 'action-tab')).toMatchObject({
s: 'action-tab',
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('deduplicates running project actions by resolved cwd and action id across session ids and clients', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
const first = createResponse();
await create({
body: {
sessionId: 'action-a',
cwd: '/repo/./nested/..',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, first);
expect(first.statusCode).toBe(200);
const adopted = createResponse();
await create({
body: {
sessionId: 'action-b',
cwd: '/repo',
mode: 'command',
command: 'npm run build --watch',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
},
}, adopted);
expect(adopted.statusCode).toBe(200);
expect(adopted.body).toEqual({
sessionId: 'action-a',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects purpose mismatches when the same session id is reused for a different action', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const mismatch = createResponse();
await create({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run test',
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
},
}, mismatch);
expect(mismatch.statusCode).toBe(400);
expect(mismatch.body).toEqual({ error: 'Terminal session is already running with a different purpose' });
} finally { await harness.runtime.shutdown(); }
});
it('keeps an immediately exited command session attachable once listeners are registered', async () => {
const app = createHttpTestApp();
const server = http.createServer(app);
const runtime = createRuntime(server, {
app,
loadPtyProvider: async () => ({
backend: 'fake-pty',
spawn: async () => {
const dataHandlers = new Set();
const exitHandlers = new Set();
return {
pid: 404,
write() {},
resize() {},
kill() {},
onData(handler) { dataHandlers.add(handler); return { dispose: () => dataHandlers.delete(handler) }; },
onExit(handler) {
exitHandlers.add(handler);
queueMicrotask(() => {
for (const registered of exitHandlers) registered({ exitCode: 0, signal: 0 });
});
return { dispose: () => exitHandlers.delete(handler) };
},
};
},
}),
terminalTerminationGraceMs: 10,
fs: { promises: { stat: async () => ({ isDirectory: () => true }) } },
searchPathFor: () => '/bin/sh',
isExecutable: () => true,
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const base = `http://127.0.0.1:${port}`;
const socketUrl = `ws://127.0.0.1:${port}/api/terminal/ws`;
const sockets = [];
try {
const created = await fetch(`${base}/api/terminal/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: 'fast-exit',
cwd: '/repo',
mode: 'command',
command: 'true',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
}),
});
expect(created.status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 0));
const socket = await openTerminalSocket(socketUrl);
sockets.push(socket.socket);
socket.socket.send(createTerminalWsControlFrame({ t: 'attach', v: 3, s: 'fast-exit' }));
expect(await socket.next('snapshot', 'fast-exit')).toMatchObject({
s: 'fast-exit',
status: 'exited',
exitCode: 0,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-fast' },
});
} finally {
for (const socket of sockets) socket.terminate();
await runtime.shutdown();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}, 15_000);
it('tombstones a pending create when delete arrives first and kills the eventual pty', async () => {
const spawnDeferred = deferred();
const harness = createHarness({ spawnDeferred });
try {
const create = harness.routes.post.get('/api/terminal/create');
const close = harness.routes.delete.get('/api/terminal/:sessionId');
const created = createResponse();
const closed = createResponse();
const createPromise = create({
body: {
sessionId: 'pending-action',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-pending' },
},
}, created);
await Promise.resolve();
const closePromise = close({ params: { sessionId: 'pending-action' } }, closed);
spawnDeferred.resolve();
await Promise.all([createPromise, closePromise]);
expect(closed.statusCode).toBe(200);
expect(closed.body).toEqual({ success: true });
expect(created.statusCode).toBe(400);
expect(created.body).toEqual({ error: 'Terminal session was closed during creation' });
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: {} }, listed);
expect(listed.body).toEqual({ sessions: [] });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(true);
} finally { await harness.runtime.shutdown(); }
});
it('rejects restart for command-mode sessions', async () => {
const harness = createHarness();
try {
await harness.routes.post.get('/api/terminal/create')({
body: {
sessionId: 'action-tab',
cwd: '/repo',
mode: 'command',
command: 'npm run build',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
},
}, createResponse());
const restarted = createResponse();
await harness.routes.post.get('/api/terminal/:sessionId/restart')({ params: { sessionId: 'action-tab' }, body: {} }, restarted);
expect(restarted.statusCode).toBe(400);
expect(restarted.body).toEqual({ error: 'Command-mode terminal sessions cannot be restarted' });
expect(harness.processes).toHaveLength(1);
expect(harness.processes[0].killed).toBe(false);
} finally { await harness.runtime.shutdown(); }
});
});
@@ -1,5 +1,6 @@
const TERMINAL_SHELL_IDS = ['bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu'];
const TERMINAL_SHELL_ID_SET = new Set(TERMINAL_SHELL_IDS);
const isString = (value) => String(value) === value;
export const normalizeTerminalShell = (value) => {
if (typeof value !== 'string') return null;
@@ -24,6 +25,22 @@ export const getTerminalShellLoginArgs = (executable, platform = process.platfor
return null;
};
export const buildTerminalShellLaunch = (executable, { mode = 'interactive', command = null, loginShell = false, platform = process.platform } = {}) => {
const loginArgs = loginShell ? getTerminalShellLoginArgs(executable, platform) : [];
if (!loginArgs) throw new Error(`Terminal shell "${shellIdFromPath(executable) ?? executable}" does not support login mode`);
if (mode === 'interactive') return { executable, args: loginArgs };
const trimmedCommand = isString(command) ? command.trim() : '';
if (!trimmedCommand) throw new Error('Terminal command is required');
const id = shellIdFromPath(executable);
if (id === 'nu') return { executable, args: [...loginArgs, '-c', trimmedCommand] };
if (id === 'pwsh' || id === 'powershell') return { executable, args: [...loginArgs, '-Command', trimmedCommand] };
if (id === 'cmd') return { executable, args: ['/d', '/s', '/c', trimmedCommand] };
return { executable, args: [...loginArgs, '-i', '-c', trimmedCommand] };
};
export const createTerminalShellResolver = ({ fs, path, searchPathFor, isExecutable, buildAugmentedPath = () => env.PATH || '', platform = process.platform, env = process.env }) => {
const resolveExecutable = (candidate) => {
if (!candidate) return null;
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createTerminalShellResolver, getTerminalShellLoginArgs } from './shells.js';
import * as shells from './shells.js';
const { createTerminalShellResolver, getTerminalShellLoginArgs } = shells;
const createResolver = ({ platform = 'linux', env = {}, augmentedPath = '/augmented/bin', executables = [] } = {}) => {
const available = new Set(executables);
@@ -70,4 +72,33 @@ describe('terminal shell resolver', () => {
expect(getTerminalShellLoginArgs('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'win32')).toBeNull();
expect(getTerminalShellLoginArgs('/bin/dash', 'linux')).toBeNull();
});
it('builds interactive shell launches by shell family', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(buildLaunch('/bin/bash', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/bin/bash', args: ['-l'] });
expect(buildLaunch('/opt/homebrew/bin/fish', { mode: 'interactive', loginShell: true, platform: 'darwin' })).toEqual({ executable: '/opt/homebrew/bin/fish', args: ['--login'] });
expect(buildLaunch('/usr/bin/nu', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/nu', args: ['--login'] });
expect(buildLaunch('/usr/bin/pwsh', { mode: 'interactive', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/pwsh', args: ['-Login'] });
expect(buildLaunch('C:\\Windows\\System32\\cmd.exe', { mode: 'interactive', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Windows\\System32\\cmd.exe', args: [] });
});
it('builds command launches by shell family', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(buildLaunch('/bin/zsh', { mode: 'command', command: 'printf ready', loginShell: true, platform: 'linux' })).toEqual({ executable: '/bin/zsh', args: ['-l', '-i', '-c', 'printf ready'] });
expect(buildLaunch('/opt/homebrew/bin/fish', { mode: 'command', command: 'echo ready', loginShell: true, platform: 'darwin' })).toEqual({ executable: '/opt/homebrew/bin/fish', args: ['--login', '-i', '-c', 'echo ready'] });
expect(buildLaunch('/usr/bin/nu', { mode: 'command', command: 'ls', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/nu', args: ['--login', '-c', 'ls'] });
expect(buildLaunch('/usr/bin/pwsh', { mode: 'command', command: 'Get-ChildItem', loginShell: true, platform: 'linux' })).toEqual({ executable: '/usr/bin/pwsh', args: ['-Login', '-Command', 'Get-ChildItem'] });
expect(buildLaunch('C:\\Program Files\\PowerShell\\7\\pwsh.exe', { mode: 'command', command: 'Get-Date', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', args: ['-Command', 'Get-Date'] });
expect(buildLaunch('C:\\Windows\\System32\\cmd.exe', { mode: 'command', command: 'dir', loginShell: false, platform: 'win32' })).toEqual({ executable: 'C:\\Windows\\System32\\cmd.exe', args: ['/d', '/s', '/c', 'dir'] });
});
it('rejects unsupported login and command combinations', () => {
const buildLaunch = shells.buildTerminalShellLaunch;
expect(() => buildLaunch('/bin/sh', { mode: 'interactive', loginShell: true, platform: 'linux' })).toThrow('does not support login mode');
expect(() => buildLaunch('/bin/dash', { mode: 'command', command: 'pwd', loginShell: true, platform: 'linux' })).toThrow('does not support login mode');
expect(() => buildLaunch('/bin/bash', { mode: 'command', command: '', loginShell: false, platform: 'linux' })).toThrow('Terminal command is required');
});
});