mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 12:16:00 +08:00
Interactive shells could resolve bundled or system CLIs before user-installed npm binaries. Move existing user npm global directories to the front of PATH while preserving all other entries. Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
550 lines
17 KiB
TypeScript
550 lines
17 KiB
TypeScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import pty, { type IPty } from 'node-pty';
|
|
import { WebSocket, type RawData } from 'ws';
|
|
|
|
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
|
|
|
type ShellIncomingMessage = {
|
|
type?: string;
|
|
data?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
projectPath?: string;
|
|
sessionId?: string;
|
|
hasSession?: boolean;
|
|
provider?: string;
|
|
initialCommand?: string;
|
|
isPlainShell?: boolean;
|
|
forceRestart?: boolean;
|
|
};
|
|
|
|
type PtySessionEntry = {
|
|
pty: IPty;
|
|
ws: WebSocket | null;
|
|
buffer: string[];
|
|
timeoutId: NodeJS.Timeout | null;
|
|
projectPath: string;
|
|
sessionId: string | null;
|
|
};
|
|
|
|
const ptySessionsMap = new Map<string, PtySessionEntry>();
|
|
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
|
|
|
type ShellWebSocketDependencies = {
|
|
resolveProviderSessionId: (
|
|
sessionId: string,
|
|
provider: string,
|
|
) => string | null | undefined;
|
|
stripAnsiSequences: (content: string) => string;
|
|
normalizeDetectedUrl: (url: string) => string | null;
|
|
extractUrlsFromText: (content: string) => string[];
|
|
shouldAutoOpenUrlFromOutput: (content: string) => boolean;
|
|
};
|
|
|
|
/**
|
|
* Reads a string field from untyped payloads and falls back when absent.
|
|
*/
|
|
function readString(value: unknown, fallback = ''): string {
|
|
return typeof value === 'string' ? value : fallback;
|
|
}
|
|
|
|
/**
|
|
* Reads a boolean field from untyped payloads and falls back when absent.
|
|
*/
|
|
function readBoolean(value: unknown, fallback = false): boolean {
|
|
return typeof value === 'boolean' ? value : fallback;
|
|
}
|
|
|
|
/**
|
|
* Reads a finite number field from untyped payloads and falls back when absent.
|
|
*/
|
|
function readNumber(value: unknown, fallback: number): number {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
/**
|
|
* Parses incoming websocket shell messages and keeps processing safe when
|
|
* malformed payloads are received.
|
|
*/
|
|
function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
|
const payload = parseIncomingJsonObject(rawMessage);
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
return payload as ShellIncomingMessage;
|
|
}
|
|
|
|
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
|
|
|
function resolveResumeSessionId(
|
|
message: ShellIncomingMessage,
|
|
dependencies: ShellWebSocketDependencies
|
|
): string {
|
|
const hasSession = readBoolean(message.hasSession);
|
|
const sessionId = readString(message.sessionId);
|
|
const provider = readString(message.provider, 'claude');
|
|
|
|
if (!hasSession || !sessionId) {
|
|
return '';
|
|
}
|
|
|
|
let resumeSessionId: string | null | undefined;
|
|
try {
|
|
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
|
|
} catch (error) {
|
|
console.error('Failed to resolve provider session ID:', error);
|
|
resumeSessionId = undefined;
|
|
}
|
|
|
|
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
|
|
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
|
|
return '';
|
|
}
|
|
|
|
return resolvedSessionId;
|
|
}
|
|
|
|
/**
|
|
* Resolves provider command line for plain shell and agent-backed shell modes.
|
|
*/
|
|
function buildShellCommand(
|
|
message: ShellIncomingMessage,
|
|
dependencies: ShellWebSocketDependencies
|
|
): string {
|
|
const hasSession = readBoolean(message.hasSession);
|
|
const initialCommand = readString(message.initialCommand);
|
|
const provider = readString(message.provider, 'claude');
|
|
const resumeSessionId = resolveResumeSessionId(message, dependencies);
|
|
const isPlainShell =
|
|
readBoolean(message.isPlainShell) ||
|
|
(!!initialCommand && !hasSession) ||
|
|
provider === 'plain-shell';
|
|
|
|
if (isPlainShell) {
|
|
return initialCommand;
|
|
}
|
|
|
|
if (provider === 'cursor') {
|
|
if (resumeSessionId) {
|
|
return `cursor-agent --resume="${resumeSessionId}"`;
|
|
}
|
|
return 'cursor-agent';
|
|
}
|
|
|
|
if (provider === 'codex') {
|
|
if (resumeSessionId) {
|
|
if (os.platform() === 'win32') {
|
|
return `codex resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
|
}
|
|
return `codex resume "${resumeSessionId}" || codex`;
|
|
}
|
|
return 'codex';
|
|
}
|
|
|
|
if (provider === 'gemini') {
|
|
const command = initialCommand || 'gemini';
|
|
if (resumeSessionId) {
|
|
return `${command} --resume "${resumeSessionId}"`;
|
|
}
|
|
return command;
|
|
}
|
|
|
|
if (provider === 'opencode') {
|
|
if (resumeSessionId) {
|
|
return `opencode --session "${resumeSessionId}"`;
|
|
}
|
|
return initialCommand || 'opencode';
|
|
}
|
|
|
|
const command = initialCommand || 'claude';
|
|
if (resumeSessionId) {
|
|
if (os.platform() === 'win32') {
|
|
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
}
|
|
return `claude --resume "${resumeSessionId}" || claude`;
|
|
}
|
|
return command;
|
|
}
|
|
|
|
function readEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
|
const resolvedKey = Object.keys(env).find((envKey) => envKey.toLowerCase() === key.toLowerCase());
|
|
return resolvedKey ? env[resolvedKey] : undefined;
|
|
}
|
|
|
|
function getPathEnvKey(env: NodeJS.ProcessEnv): string {
|
|
return Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
|
}
|
|
|
|
function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; value: string | undefined } {
|
|
const pathKey = getPathEnvKey(env);
|
|
const currentPath = env[pathKey];
|
|
if (!currentPath) {
|
|
return { key: pathKey, value: currentPath };
|
|
}
|
|
|
|
const delimiter = path.delimiter;
|
|
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
|
const npmPrefix = readEnvValue(env, 'npm_config_prefix');
|
|
const appData = readEnvValue(env, 'APPDATA');
|
|
const candidates = [
|
|
npmPrefix || '',
|
|
npmPrefix ? path.join(npmPrefix, 'bin') : '',
|
|
appData ? path.join(appData, 'npm') : '',
|
|
path.join(os.homedir(), 'AppData', 'Roaming', 'npm'),
|
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
].filter(Boolean);
|
|
|
|
const normalizedPathEntries = pathEntries.map((entry) => os.platform() === 'win32' ? entry.toLowerCase() : entry);
|
|
const preferredEntries = candidates.filter((candidate, index) => {
|
|
const normalizedCandidate = os.platform() === 'win32' ? candidate.toLowerCase() : candidate;
|
|
return (
|
|
candidates.indexOf(candidate) === index &&
|
|
normalizedPathEntries.includes(normalizedCandidate)
|
|
);
|
|
});
|
|
|
|
if (preferredEntries.length === 0) {
|
|
return { key: pathKey, value: currentPath };
|
|
}
|
|
|
|
const normalizedPreferredEntries = preferredEntries.map((entry) =>
|
|
os.platform() === 'win32' ? entry.toLowerCase() : entry
|
|
);
|
|
|
|
const value = [
|
|
...preferredEntries,
|
|
...pathEntries.filter((entry) => {
|
|
const normalizedEntry = os.platform() === 'win32' ? entry.toLowerCase() : entry;
|
|
return !normalizedPreferredEntries.includes(normalizedEntry);
|
|
}),
|
|
].join(delimiter);
|
|
|
|
return { key: pathKey, value };
|
|
}
|
|
|
|
/**
|
|
* Handles websocket connections used by the standalone shell terminal UI.
|
|
*/
|
|
export function handleShellConnection(
|
|
ws: WebSocket,
|
|
dependencies: ShellWebSocketDependencies
|
|
): void {
|
|
console.log('[INFO] Shell websocket connected');
|
|
|
|
let shellProcess: IPty | null = null;
|
|
let ptySessionKey: string | null = null;
|
|
let urlDetectionBuffer = '';
|
|
const announcedAuthUrls = new Set<string>();
|
|
|
|
ws.on('message', async (rawMessage) => {
|
|
try {
|
|
const data = parseShellMessage(rawMessage);
|
|
if (!data?.type) {
|
|
throw new Error('Invalid websocket payload');
|
|
}
|
|
|
|
if (data.type === 'init') {
|
|
const projectPath = readString(data.projectPath, process.cwd());
|
|
const sessionId = readString(data.sessionId) || null;
|
|
const hasSession = readBoolean(data.hasSession);
|
|
const provider = readString(data.provider, 'claude');
|
|
const initialCommand = readString(data.initialCommand);
|
|
const forceRestart = readBoolean(data.forceRestart);
|
|
const isPlainShell =
|
|
readBoolean(data.isPlainShell) ||
|
|
(!!initialCommand && !hasSession) ||
|
|
provider === 'plain-shell';
|
|
|
|
urlDetectionBuffer = '';
|
|
announcedAuthUrls.clear();
|
|
|
|
const isLoginCommand =
|
|
!!initialCommand &&
|
|
(initialCommand.includes('setup-token') ||
|
|
initialCommand.includes('cursor-agent login') ||
|
|
initialCommand.includes('auth login'));
|
|
|
|
const commandSuffix =
|
|
isPlainShell && initialCommand
|
|
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
|
: '';
|
|
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
|
|
|
if (isLoginCommand || forceRestart) {
|
|
const oldSession = ptySessionsMap.get(ptySessionKey);
|
|
if (oldSession) {
|
|
if (oldSession.timeoutId) {
|
|
clearTimeout(oldSession.timeoutId);
|
|
}
|
|
oldSession.pty.kill();
|
|
ptySessionsMap.delete(ptySessionKey);
|
|
}
|
|
}
|
|
|
|
const existingSession =
|
|
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
|
|
if (existingSession) {
|
|
shellProcess = existingSession.pty;
|
|
if (existingSession.timeoutId) {
|
|
clearTimeout(existingSession.timeoutId);
|
|
}
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: '\x1b[36m[Reconnected to existing session]\x1b[0m\r\n',
|
|
})
|
|
);
|
|
|
|
if (existingSession.buffer.length > 0) {
|
|
existingSession.buffer.forEach((bufferedData) => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: bufferedData,
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
existingSession.ws = ws;
|
|
return;
|
|
}
|
|
|
|
const resolvedProjectPath = path.resolve(projectPath);
|
|
try {
|
|
const stats = fs.statSync(resolvedProjectPath);
|
|
if (!stats.isDirectory()) {
|
|
throw new Error('Not a directory');
|
|
}
|
|
} catch {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
|
|
return;
|
|
}
|
|
|
|
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
|
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
|
return;
|
|
}
|
|
|
|
const shellCommand = buildShellCommand(data, dependencies);
|
|
const resumeSessionId = resolveResumeSessionId(data, dependencies);
|
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
const shellArgs =
|
|
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
|
const termCols = readNumber(data.cols, 80);
|
|
const termRows = readNumber(data.rows, 24);
|
|
const prioritizedPath = prioritizeUserNpmGlobalBin(process.env);
|
|
|
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
name: 'xterm-256color',
|
|
cols: termCols,
|
|
rows: termRows,
|
|
cwd: resolvedProjectPath,
|
|
env: {
|
|
...process.env,
|
|
[prioritizedPath.key]: prioritizedPath.value,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
FORCE_COLOR: '3',
|
|
},
|
|
});
|
|
|
|
ptySessionsMap.set(ptySessionKey, {
|
|
pty: shellProcess,
|
|
ws,
|
|
buffer: [],
|
|
timeoutId: null,
|
|
projectPath,
|
|
sessionId,
|
|
});
|
|
|
|
shellProcess.onData((chunk) => {
|
|
if (!ptySessionKey) {
|
|
return;
|
|
}
|
|
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
if (session.buffer.length < 5000) {
|
|
session.buffer.push(chunk);
|
|
} else {
|
|
session.buffer.shift();
|
|
session.buffer.push(chunk);
|
|
}
|
|
|
|
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
let outputData = chunk;
|
|
const cleanChunk = dependencies.stripAnsiSequences(chunk);
|
|
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
|
|
|
outputData = outputData.replace(
|
|
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
'[INFO] Opening in browser: $1'
|
|
);
|
|
|
|
const emitAuthUrl = (detectedUrl: string, autoOpen = false) => {
|
|
const normalizedUrl = dependencies.normalizeDetectedUrl(detectedUrl);
|
|
if (!normalizedUrl) {
|
|
return;
|
|
}
|
|
|
|
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
|
if (isNewUrl) {
|
|
announcedAuthUrls.add(normalizedUrl);
|
|
session.ws?.send(
|
|
JSON.stringify({
|
|
type: 'auth_url',
|
|
url: normalizedUrl,
|
|
autoOpen,
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
const normalizedDetectedUrls = dependencies.extractUrlsFromText(urlDetectionBuffer)
|
|
.map((url) => dependencies.normalizeDetectedUrl(url))
|
|
.filter((url): url is string => Boolean(url));
|
|
|
|
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter(
|
|
(url, _, urls) =>
|
|
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
|
);
|
|
|
|
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
|
|
|
if (
|
|
dependencies.shouldAutoOpenUrlFromOutput(cleanChunk) &&
|
|
dedupedDetectedUrls.length > 0
|
|
) {
|
|
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
|
current.length > longest.length ? current : longest
|
|
);
|
|
emitAuthUrl(bestUrl, true);
|
|
}
|
|
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: outputData,
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
shellProcess.onExit((exitCode) => {
|
|
if (!ptySessionKey) {
|
|
return;
|
|
}
|
|
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (session && session.pty !== shellProcess) {
|
|
return;
|
|
}
|
|
|
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
session.ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${
|
|
exitCode.signal != null ? ` (${exitCode.signal})` : ''
|
|
}\x1b[0m\r\n`,
|
|
})
|
|
);
|
|
}
|
|
|
|
if (session?.timeoutId) {
|
|
clearTimeout(session.timeoutId);
|
|
}
|
|
|
|
ptySessionsMap.delete(ptySessionKey);
|
|
shellProcess = null;
|
|
});
|
|
|
|
let welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
if (!isPlainShell) {
|
|
const providerName =
|
|
provider === 'cursor'
|
|
? 'Cursor'
|
|
: provider === 'codex'
|
|
? 'Codex'
|
|
: provider === 'gemini'
|
|
? 'Gemini'
|
|
: provider === 'opencode'
|
|
? 'OpenCode'
|
|
: 'Claude';
|
|
welcomeMsg = hasSession && resumeSessionId
|
|
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
|
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
}
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: welcomeMsg,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'input') {
|
|
if (shellProcess) {
|
|
shellProcess.write(readString(data.data));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.type === 'resize') {
|
|
if (shellProcess) {
|
|
shellProcess.resize(readNumber(data.cols, 80), readNumber(data.rows, 24));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('[ERROR] Shell WebSocket error:', message);
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'output',
|
|
data: `\r\n\x1b[31mError: ${message}\x1b[0m\r\n`,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
if (!ptySessionKey) {
|
|
return;
|
|
}
|
|
|
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
session.ws = null;
|
|
session.timeoutId = setTimeout(() => {
|
|
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
|
|
return;
|
|
}
|
|
|
|
session.pty.kill();
|
|
ptySessionsMap.delete(ptySessionKey as string);
|
|
}, PTY_SESSION_TIMEOUT);
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('[ERROR] Shell WebSocket error:', error);
|
|
});
|
|
}
|