From 9a392c3ed2ada10205a02f71ff1268d9e6f2011e Mon Sep 17 00:00:00 2001 From: xiaobaifly7 Date: Wed, 20 May 2026 03:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20Claude=20wrapper=20=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/README.md | 2 +- cli/src/claude/claudeLocal.ts | 5 +- cli/src/claude/sdk/query.test.ts | 20 +++ cli/src/claude/sdk/query.ts | 14 +- cli/src/claude/sdk/utils.test.ts | 150 +++++++++++++++++++++ cli/src/claude/sdk/utils.ts | 217 +++++++++++++++++++++++++++++-- docs/guide/faq.md | 2 + 7 files changed, 391 insertions(+), 19 deletions(-) create mode 100644 cli/src/claude/sdk/utils.test.ts diff --git a/cli/README.md b/cli/README.md index 127fa85978..8555ca9521 100644 --- a/cli/README.md +++ b/cli/README.md @@ -89,7 +89,7 @@ See `src/configuration.ts` for all options. - `HAPI_HOME` - Config/data directory (default: ~/.hapi). - `HAPI_EXPERIMENTAL` - Enable experimental features (true/1/yes). - `HAPI_EXTRA_HEADERS_JSON` - JSON object of extra headers to send on CLI → hub requests, e.g. `{"Cookie":"CF_Authorization=..."}`. -- `HAPI_CLAUDE_PATH` - Path to a specific `claude` executable. +- `HAPI_CLAUDE_PATH` - Path or command name for a specific Claude Code executable or Claude-compatible wrapper. HAPI runs a startup preflight and reports missing paths, failing wrappers, and likely wrapper recursion before launching a session. - `HAPI_HTTP_MCP_URL` - Default MCP target for `hapi mcp`. ### Runner diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 04edc2ee0f..19c8a9181b 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -8,7 +8,7 @@ import { withBunRuntimeEnv } from "@/utils/bunRuntime"; import { spawnWithTerminalGuard } from "@/utils/spawnWithTerminalGuard"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { stripNewlinesForWindowsShellArg } from "@/utils/shellEscape"; -import { getDefaultClaudeCodePath } from "./sdk/utils"; +import { getClaudeCodeExecutableShell, getDefaultClaudeCodePath } from "./sdk/utils"; export async function claudeLocal(opts: { abort: AbortSignal, @@ -94,6 +94,7 @@ export async function claudeLocal(opts: { // Get Claude executable path (absolute path on Windows for shell: false) const claudeCommand = getDefaultClaudeCodePath(); logger.debug(`[ClaudeLocal] Using claude executable: ${claudeCommand}`); + const useShell = getClaudeCodeExecutableShell(claudeCommand); // Spawn the process try { @@ -108,7 +109,7 @@ export async function claudeLocal(opts: { installHint: 'Claude CLI', includeCause: true, logExit: true, - shell: false // Use absolute path, no shell needed + shell: useShell }); } finally { cleanupMcpConfig?.(); diff --git a/cli/src/claude/sdk/query.test.ts b/cli/src/claude/sdk/query.test.ts index 7286024959..5d4d7e89e9 100644 --- a/cli/src/claude/sdk/query.test.ts +++ b/cli/src/claude/sdk/query.test.ts @@ -79,6 +79,26 @@ describe('Query', () => { await expect(result.next()).rejects.toThrow('prompt failed') }) + it('allows command-only Claude wrappers from HAPI_CLAUDE_PATH', async () => { + const child = createFakeChild() + spawnMock.mockReturnValueOnce(child) + process.env.HAPI_CLAUDE_PATH = 'claude' + + const { query } = await import('./query') + const result = query({ prompt: 'hello' }) + child.stdout.end() + child.emit('close', 0) + + await expect(result.next()).resolves.toEqual({ done: true, value: undefined }) + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + expect.arrayContaining(['--print', 'hello']), + expect.objectContaining({ + shell: process.platform === 'win32' + }) + ) + }) + it('fails fast after cleanup timeout when prompt cleanup hangs', async () => { const child = createFakeChild() spawnMock.mockReturnValueOnce(child) diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 246fb32afc..d52fa0243e 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -22,7 +22,7 @@ import { type PermissionResult, AbortError } from './types' -import { getDefaultClaudeCodePath, logDebug, streamToStdin } from './utils' +import { getClaudeCodeExecutableShell, getDefaultClaudeCodePath, isClaudeCodeCommandOnly, logDebug, streamToStdin } from './utils' import { withBunRuntimeEnv } from '@/utils/bunRuntime' import { killProcessByChildProcess } from '@/utils/process' import { stripNewlinesForWindowsShellArg } from '@/utils/shellEscape' @@ -362,10 +362,10 @@ export function query(config: { args.push('--input-format', 'stream-json') } - // Determine how to spawn Claude Code - // - If it's just 'claude' command → spawn('claude', args) with shell on Windows - // - If it's a full path to binary or script → spawn(path, args) - const isCommandOnly = pathToClaudeCodeExecutable === 'claude' + // Determine how to spawn Claude Code. + // Windows shell wrappers (.cmd/.bat) need shell mode, while real binaries + // should keep shell disabled to avoid cmd.exe path resolution issues. + const isCommandOnly = isClaudeCodeCommandOnly(pathToClaudeCodeExecutable) // Validate executable path (skip for command-only mode) if (!isCommandOnly && !existsSync(pathToClaudeCodeExecutable)) { @@ -386,9 +386,7 @@ export function query(config: { stdio: ['pipe', 'pipe', 'pipe'], signal: config.options?.abort, env: spawnEnv, - // Use shell: false with absolute path from getDefaultClaudeCodePath() - // This avoids cmd.exe resolution issues on Windows - shell: false, + shell: getClaudeCodeExecutableShell(pathToClaudeCodeExecutable), // Hide transient console windows on Windows when spawning Claude Code windowsHide: process.platform === 'win32' }) as ChildProcessWithoutNullStreams diff --git a/cli/src/claude/sdk/utils.test.ts b/cli/src/claude/sdk/utils.test.ts new file mode 100644 index 0000000000..81bcb26601 --- /dev/null +++ b/cli/src/claude/sdk/utils.test.ts @@ -0,0 +1,150 @@ +import { existsSync, readFileSync } from 'node:fs' +import { execFileSync, execSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), + execSync: vi.fn() +})) + +vi.mock('node:os', () => ({ + homedir: () => '/home/test' +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})) + +const existsSyncMock = vi.mocked(existsSync) +const readFileSyncMock = vi.mocked(readFileSync) +const execFileSyncMock = vi.mocked(execFileSync) +const execSyncMock = vi.mocked(execSync) + +afterEach(() => { + vi.clearAllMocks() + delete process.env.HAPI_CLAUDE_PATH +}) + +describe('resolveClaudeCodeExecutable', () => { + it('uses HAPI_CLAUDE_PATH after validating the executable', async () => { + process.env.HAPI_CLAUDE_PATH = '/opt/wrapper/claude' + existsSyncMock.mockReturnValue(true) + execFileSyncMock.mockReturnValue('1.0.0') + + const { resolveClaudeCodeExecutable } = await import('./utils') + + expect(resolveClaudeCodeExecutable()).toEqual({ + path: '/opt/wrapper/claude', + source: 'env' + }) + expect(execFileSyncMock).toHaveBeenCalledWith( + '/opt/wrapper/claude', + ['--version'], + expect.objectContaining({ + timeout: 5_000 + }) + ) + }) + + it('rejects a missing HAPI_CLAUDE_PATH', async () => { + process.env.HAPI_CLAUDE_PATH = '/missing/claude' + existsSyncMock.mockReturnValue(false) + + const { resolveClaudeCodeExecutable } = await import('./utils') + + expect(() => resolveClaudeCodeExecutable()).toThrow('HAPI_CLAUDE_PATH does not exist: /missing/claude') + }) + + it('reports preflight timeouts with wrapper guidance', async () => { + process.env.HAPI_CLAUDE_PATH = '/opt/wrapper/claude' + existsSyncMock.mockReturnValue(true) + execFileSyncMock.mockImplementation(() => { + const error = new Error('spawn timed out') as Error & { killed: boolean } + error.killed = true + throw error + }) + + const { resolveClaudeCodeExecutable } = await import('./utils') + + expect(() => resolveClaudeCodeExecutable()).toThrow('check for recursive invocation') + }) + + it('does not reject non-timeout --version failures for compatible wrappers', async () => { + process.env.HAPI_CLAUDE_PATH = '/opt/wrapper/claude' + existsSyncMock.mockReturnValue(true) + execFileSyncMock.mockImplementation(() => { + const error = new Error('unsupported flag') as Error & { status: number; stderr: Buffer } + error.status = 1 + error.stderr = Buffer.from('unsupported flag') + throw error + }) + + const { resolveClaudeCodeExecutable } = await import('./utils') + + expect(resolveClaudeCodeExecutable()).toEqual({ + path: '/opt/wrapper/claude', + source: 'env' + }) + }) + + it('detects obvious wrapper recursion in script files', async () => { + process.env.HAPI_CLAUDE_PATH = '/opt/wrapper/claude.cmd' + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue('@echo off\n"/opt/wrapper/claude.cmd" %*\n') + + const { resolveClaudeCodeExecutable } = await import('./utils') + + expect(() => resolveClaudeCodeExecutable()).toThrow('wrapper appears to call itself') + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('detects Windows PATH wrappers that point back to HAPI_CLAUDE_PATH', async () => { + process.env.HAPI_CLAUDE_PATH = 'C:\\Tools\\wrapper\\claude.exe' + existsSyncMock.mockReturnValue(true) + execSyncMock.mockImplementation((command) => { + if (command === 'where claude') { + return 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd' + } + return '' + }) + readFileSyncMock.mockReturnValue('@echo off\n"C:\\Tools\\wrapper\\claude.exe" %*\n') + + const { resolveClaudeCodeExecutable } = await import('./utils') + + if (process.platform === 'win32') { + expect(() => resolveClaudeCodeExecutable()).toThrow('may recurse through PATH wrapper') + expect(execFileSyncMock).not.toHaveBeenCalled() + } else { + expect(resolveClaudeCodeExecutable()).toEqual({ + path: 'C:\\Tools\\wrapper\\claude.exe', + source: 'env' + }) + } + }) + + it('falls back to global claude when HAPI_CLAUDE_PATH is not set', async () => { + existsSyncMock.mockImplementation((candidate) => String(candidate) === 'C:\\Tools\\claude.exe') + execSyncMock.mockReturnValue('C:\\Tools\\claude.exe') + + const { resolveClaudeCodeExecutable } = await import('./utils') + + const resolved = resolveClaudeCodeExecutable() + expect(resolved.source).toBe('auto') + expect(resolved.path).toMatch(/claude(\.exe)?$/) + }) +}) + +describe('getClaudeCodeExecutableShell', () => { + it('does not use shell for real executable paths on non-Windows platforms', async () => { + const { getClaudeCodeExecutableShell } = await import('./utils') + + expect(getClaudeCodeExecutableShell('/opt/claude')).toBe(false) + }) +}) diff --git a/cli/src/claude/sdk/utils.ts b/cli/src/claude/sdk/utils.ts index e3518d1b05..6794bdfe14 100644 --- a/cli/src/claude/sdk/utils.ts +++ b/cli/src/claude/sdk/utils.ts @@ -3,18 +3,213 @@ * Provides helper functions for path resolution and logging */ -import { existsSync } from 'node:fs' -import { execSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { execFileSync, execSync } from 'node:child_process' import { homedir } from 'node:os' +import * as path from 'node:path' import { logger } from '@/ui/logger' +const CLAUDE_PREFLIGHT_TIMEOUT_MS = 5_000 +const WINDOWS_SHELL_SCRIPT_EXTENSIONS = new Set(['.bat', '.cmd', '.ps1']) + +export type ClaudeCodeExecutableSource = 'env' | 'auto' + +export interface ClaudeCodeExecutable { + path: string + source: ClaudeCodeExecutableSource +} + +function isPathLike(value: string): boolean { + return value.includes('/') || value.includes('\\') || /^[a-zA-Z]:/.test(value) +} + +export function isClaudeCodeCommandOnly(value: string): boolean { + return !isPathLike(value) +} + +function isWindowsShellScript(value: string): boolean { + return WINDOWS_SHELL_SCRIPT_EXTENSIONS.has(path.extname(value).toLowerCase()) +} + +export function getClaudeCodeExecutableShell(value: string): boolean { + return process.platform === 'win32' && (isClaudeCodeCommandOnly(value) || isWindowsShellScript(value)) +} + +function normalizePathForCompare(value: string): string { + return path.resolve(value).toLowerCase() +} + +function getCurrentEntrypointPaths(): string[] { + return [process.execPath, process.argv[1]] + .filter((value): value is string => Boolean(value)) + .map(normalizePathForCompare) +} + +function isTimeoutError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const maybeError = error as { killed?: boolean; signal?: string; message?: string } + return Boolean( + maybeError.killed || + maybeError.signal === 'SIGTERM' || + maybeError.message?.includes('ETIMEDOUT') || + maybeError.message?.includes('timed out') + ) +} + +function formatExecFailure(error: unknown): string { + if (!error || typeof error !== 'object') { + return String(error) + } + + const maybeError = error as { status?: number; signal?: string; stderr?: Buffer | string; stdout?: Buffer | string; message?: string } + const parts: string[] = [] + + if (typeof maybeError.status === 'number') { + parts.push(`exit code ${maybeError.status}`) + } + if (maybeError.signal) { + parts.push(`signal ${maybeError.signal}`) + } + + const stderr = maybeError.stderr?.toString().trim() + const stdout = maybeError.stdout?.toString().trim() + const output = stderr || stdout || maybeError.message + if (output) { + parts.push(output.slice(0, 500)) + } + + return parts.join(': ') || 'unknown error' +} + +function assertNotCurrentHapiExecutable(customPath: string): void { + const normalizedCustomPath = normalizePathForCompare(customPath) + if (getCurrentEntrypointPaths().includes(normalizedCustomPath)) { + throw new Error( + `HAPI_CLAUDE_PATH points back to HAPI itself: ${customPath}. ` + + 'Set it to the Claude Code executable or a Claude-compatible wrapper.' + ) + } +} + +function assertNoObviousWrapperRecursion(customPath: string): void { + if (!isWindowsShellScript(customPath) && path.extname(customPath).toLowerCase() !== '.sh') { + return + } + + let content: string + try { + content = readFileSync(customPath, 'utf8').toLowerCase() + } catch { + return + } + + const normalizedCustomPath = normalizePathForCompare(customPath) + const normalizedContent = content.replace(/\//g, '\\') + const recursiveTargets = [ + customPath.toLowerCase(), + normalizedCustomPath, + ...getCurrentEntrypointPaths() + ].flatMap(value => { + const normalizedValue = value.replace(/\//g, '\\') + return [normalizedValue, `"${normalizedValue}"`, `'${normalizedValue}'`] + }) + + if (recursiveTargets.some(target => normalizedContent.includes(target))) { + throw new Error( + `HAPI_CLAUDE_PATH wrapper appears to call itself or HAPI again: ${customPath}. ` + + 'Point it at a non-recursive Claude-compatible wrapper.' + ) + } +} + +function assertNoWindowsPathWrapperLoop(customPath: string): void { + if (process.platform !== 'win32' || isClaudeCodeCommandOnly(customPath)) { + return + } + + let candidates: string[] = [] + try { + candidates = execSync('where claude', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: homedir() + }) + .split(/\r?\n/) + .map(candidate => candidate.trim()) + .filter(Boolean) + } catch { + return + } + + const normalizedCustomPath = customPath.toLowerCase().replace(/\//g, '\\') + for (const candidate of candidates) { + if (!isWindowsShellScript(candidate)) { + continue + } + + let content: string + try { + content = readFileSync(candidate, 'utf8').toLowerCase().replace(/\//g, '\\') + } catch { + continue + } + + if (content.includes(normalizedCustomPath)) { + throw new Error( + `HAPI_CLAUDE_PATH may recurse through PATH wrapper ${candidate}. ` + + `That wrapper calls ${customPath}, so a Claude-compatible wrapper that delegates to ` + + '`claude` can loop back into itself. Point PATH `claude` at the real Claude Code binary.' + ) + } + } +} + +function preflightCustomClaudePath(customPath: string): void { + if (isClaudeCodeCommandOnly(customPath)) { + logger.debug(`[Claude SDK] Using HAPI_CLAUDE_PATH command: ${customPath}`) + return + } + + if (!existsSync(customPath)) { + throw new Error(`HAPI_CLAUDE_PATH does not exist: ${customPath}`) + } + + assertNotCurrentHapiExecutable(customPath) + assertNoObviousWrapperRecursion(customPath) + assertNoWindowsPathWrapperLoop(customPath) + + try { + execFileSync(customPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + cwd: homedir(), + env: { + ...process.env, + DISABLE_AUTOUPDATER: '1' + }, + shell: getClaudeCodeExecutableShell(customPath), + timeout: CLAUDE_PREFLIGHT_TIMEOUT_MS, + windowsHide: true + }) + } catch (error) { + if (isTimeoutError(error)) { + throw new Error( + `HAPI_CLAUDE_PATH preflight timed out after ${CLAUDE_PREFLIGHT_TIMEOUT_MS}ms: ${customPath}. ` + + 'If this is a wrapper, check for recursive invocation or interactive startup prompts.' + ) + } + logger.debug(`[Claude SDK] HAPI_CLAUDE_PATH --version preflight failed for ${customPath}: ${formatExecFailure(error)}`) + } +} + /** * Find Claude executable path on Windows. * Returns absolute path to claude.exe for use with shell: false */ function findWindowsClaudePath(): string | null { const homeDir = homedir() - const path = require('node:path') // Known installation paths for Claude on Windows const candidates = [ @@ -99,11 +294,13 @@ function findGlobalClaudePath(): string | null { * Environment variables: * - HAPI_CLAUDE_PATH: Force a specific path to claude executable */ -export function getDefaultClaudeCodePath(): string { +export function resolveClaudeCodeExecutable(): ClaudeCodeExecutable { // Allow explicit override via env var - if (process.env.HAPI_CLAUDE_PATH) { - logger.debug(`[Claude SDK] Using HAPI_CLAUDE_PATH: ${process.env.HAPI_CLAUDE_PATH}`) - return process.env.HAPI_CLAUDE_PATH + const customClaudePath = process.env.HAPI_CLAUDE_PATH?.trim() + if (customClaudePath) { + preflightCustomClaudePath(customClaudePath) + logger.debug(`[Claude SDK] Using HAPI_CLAUDE_PATH: ${customClaudePath}`) + return { path: customClaudePath, source: 'env' } } // Find global claude @@ -111,7 +308,11 @@ export function getDefaultClaudeCodePath(): string { if (!globalPath) { throw new Error('Claude Code CLI not found on PATH. Install Claude Code or set HAPI_CLAUDE_PATH.') } - return globalPath + return { path: globalPath, source: 'auto' } +} + +export function getDefaultClaudeCodePath(): string { + return resolveClaudeCodeExecutable().path } /** diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 095ba25150..c470328739 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -185,6 +185,8 @@ npm install -g @anthropic-ai/claude-code export HAPI_CLAUDE_PATH=/path/to/claude ``` +`HAPI_CLAUDE_PATH` can also point to a Claude-compatible wrapper. HAPI validates the path at startup and reports missing files, wrapper failures, or likely recursive wrappers before starting the session. + ### Cursor Agent not found Install Cursor Agent CLI: