fix: resolve claude CLI absolute path to prevent execvp failures

When the web server runs in environments where `claude` isn't in PATH
(e.g., systemd services, non-login shells), node-pty's execvp(3) fails
with "No such file or directory". Fix by resolving the absolute path
to the claude binary using `which` with fallback to common installation
locations (~/.local/bin, /usr/local/bin, etc.). The resolved path is
cached for performance.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 17:43:31 +01:00
co-authored by Claude Opus 4.5
parent e55eca9824
commit 03a0cc862e
2 changed files with 110 additions and 3 deletions
+49 -1
View File
@@ -21,6 +21,53 @@ import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js';
// ============================================================================
// Claude CLI Path Resolution
// ============================================================================
/** Common installation paths for the Claude CLI binary */
const CLAUDE_COMMON_PATHS = [
`${homedir()}/.local/bin/claude`,
`${homedir()}/.claude/local/claude`,
'/usr/local/bin/claude',
'/usr/bin/claude',
`${homedir()}/.npm-global/bin/claude`,
`${homedir()}/bin/claude`,
];
/** Cached resolved path to the claude binary */
let _resolvedClaudePath: string | null = null;
/**
* Resolves the absolute path to the `claude` CLI binary.
* Uses `which` first, then falls back to common installation locations.
*/
function resolveClaudePath(): string {
if (_resolvedClaudePath) return _resolvedClaudePath;
try {
const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && existsSync(result)) {
_resolvedClaudePath = result;
return _resolvedClaudePath;
}
} catch {
// `which` failed, try common paths
}
for (const p of CLAUDE_COMMON_PATHS) {
if (existsSync(p)) {
_resolvedClaudePath = p;
return _resolvedClaudePath;
}
}
throw new Error(
'Claude CLI not found. Ensure `claude` is installed and available in PATH. ' +
'Checked: PATH lookup and common paths: ' + CLAUDE_COMMON_PATHS.join(', ')
);
}
/** Path to persisted screen session metadata */
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
@@ -169,8 +216,9 @@ export class ScreenManager extends EventEmitter {
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
// This helps prevent Claude from attempting to kill its own screen session
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName} CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`;
const claudePath = resolveClaudePath();
const cmd = mode === 'claude'
? `${envVars} claude --dangerously-skip-permissions`
? `${envVars} ${claudePath} --dangerously-skip-permissions`
: `${envVars} $SHELL`;
try {
+61 -2
View File
@@ -16,6 +16,8 @@
*/
import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
@@ -61,6 +63,61 @@ const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
// ============================================================================
// Claude CLI Path Resolution
// ============================================================================
/** Common installation paths for the Claude CLI binary */
const CLAUDE_COMMON_PATHS = [
`${process.env.HOME}/.local/bin/claude`,
`${process.env.HOME}/.claude/local/claude`,
'/usr/local/bin/claude',
'/usr/bin/claude',
`${process.env.HOME}/.npm-global/bin/claude`,
`${process.env.HOME}/bin/claude`,
];
/** Cached resolved path to the claude binary */
let _resolvedClaudePath: string | null = null;
/**
* Resolves the absolute path to the `claude` CLI binary.
*
* Uses `which` to find the binary in PATH first, then falls back to common
* installation locations. The result is cached for subsequent calls.
*
* @throws {Error} If claude CLI cannot be found in PATH or common locations
*/
function resolveClaudePath(): string {
if (_resolvedClaudePath) return _resolvedClaudePath;
// Try `which` first (respects PATH)
try {
const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && existsSync(result)) {
_resolvedClaudePath = result;
console.log('[Session] Resolved claude CLI path:', _resolvedClaudePath);
return _resolvedClaudePath;
}
} catch {
// `which` failed, try common paths
}
// Check common installation paths
for (const p of CLAUDE_COMMON_PATHS) {
if (existsSync(p)) {
_resolvedClaudePath = p;
console.log('[Session] Found claude CLI at common path:', _resolvedClaudePath);
return _resolvedClaudePath;
}
}
throw new Error(
'Claude CLI not found. Ensure `claude` is installed and available in PATH. ' +
'Checked: PATH lookup and common paths: ' + CLAUDE_COMMON_PATHS.join(', ')
);
}
// ============================================================================
// Buffer Accumulator (reduces GC pressure from string concatenation)
// ============================================================================
@@ -736,7 +793,8 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if screen is not used
if (!this.ptyProcess) {
this.ptyProcess = pty.spawn('claude', [
const claudePath = resolveClaudePath();
this.ptyProcess = pty.spawn(claudePath, [
'--dangerously-skip-permissions'
], {
name: 'xterm-256color',
@@ -1014,7 +1072,8 @@ export class Session extends EventEmitter {
// Spawn claude in a real PTY
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50));
this.ptyProcess = pty.spawn('claude', [
const claudePath = resolveClaudePath();
this.ptyProcess = pty.spawn(claudePath, [
'-p',
'--dangerously-skip-permissions',
'--output-format', 'stream-json',