Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions cli/src/claude/claudeLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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?.();
Expand Down
20 changes: 20 additions & 0 deletions cli/src/claude/sdk/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 6 additions & 8 deletions cli/src/claude/sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down
150 changes: 150 additions & 0 deletions cli/src/claude/sdk/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading