mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
29f814c312
NSIS aborts setup when `File` cannot write check-install-processes.ps1 into %TEMP%, which security software commonly blocks. Write the helper with FileWrite instead (generated from the .ps1) and fall back to the tasklist check when the write fails or the helper never runs. Installed 0.6.x uninstallers still extract the helper with `File` and fail the same way, so install over the old files when this installer saw the same block. Exit code 22 and other uninstall failures stay fatal. Also report an unexecutable sidecar (EFTYPE and friends) with its path and reinstall guidance instead of a bare "spawn EFTYPE".
853 lines
33 KiB
TypeScript
853 lines
33 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { EventEmitter } from 'node:events'
|
|
import net from 'node:net'
|
|
import http from 'node:http'
|
|
import path from 'node:path'
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { homedir, tmpdir } from 'node:os'
|
|
import {
|
|
appendHostDiagnostic,
|
|
buildSidecarEnv,
|
|
clearProxyEnv,
|
|
ADAPTER_FLAGS,
|
|
createAdapterPlan,
|
|
createServerPlan,
|
|
electronHostDiagnosticsFile,
|
|
formatStartupError,
|
|
httpToWebSocketUrl,
|
|
HOST_DIAGNOSTICS_BYTE_LIMIT,
|
|
HOST_DIAGNOSTICS_LINE_LIMIT,
|
|
killSidecar,
|
|
parseH5FixedPort,
|
|
preferredServerPorts,
|
|
pushStartupLog,
|
|
readH5FixedPort,
|
|
readLastServerPort,
|
|
reserveLocalPort,
|
|
reserveServerPort,
|
|
resolveBundledRipgrepExecutable,
|
|
resolveHostTriple,
|
|
resolveWindowsTaskkillExecutable,
|
|
RIPGREP_PATH_ENV,
|
|
SERVER_STATE_FILE,
|
|
SYSTEM_PROXY_BRIDGE_ENV,
|
|
SYSTEM_PROXY_ERROR_ENV,
|
|
spawnSidecar,
|
|
waitForServer,
|
|
withAdapterProxyBridgeEnv,
|
|
withSystemProxyBridgeEnv,
|
|
withSystemProxyErrorEnv,
|
|
windowsPowerShellOverride,
|
|
writeLastServerPort,
|
|
type SidecarChild,
|
|
} from './sidecarManager'
|
|
|
|
function fakeChild(pid = 4321) {
|
|
return { pid, kill: vi.fn() } as unknown as SidecarChild & { kill: ReturnType<typeof vi.fn> }
|
|
}
|
|
|
|
function listen(server: http.Server, host = '127.0.0.1'): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, host, () => {
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
reject(new Error('Could not resolve HTTP test port'))
|
|
return
|
|
}
|
|
resolve(address.port)
|
|
})
|
|
})
|
|
}
|
|
|
|
function close(server: http.Server): Promise<void> {
|
|
return new Promise(resolve => server.close(() => resolve()))
|
|
}
|
|
|
|
describe('Electron sidecar manager', () => {
|
|
it('places the Electron host log in the active server diagnostics directory', () => {
|
|
const portableDir = path.join(tmpdir(), 'cc-haha-portable-diagnostics')
|
|
|
|
expect(electronHostDiagnosticsFile(
|
|
{ CLAUDE_CONFIG_DIR: portableDir },
|
|
path.join(tmpdir(), 'unused-home'),
|
|
)).toBe(path.join(portableDir, 'cc-haha', 'diagnostics', 'electron-host.log'))
|
|
})
|
|
|
|
it('resolves the default Electron host log without consulting real user state', () => {
|
|
const isolatedHome = path.resolve(path.sep, '__cc_haha_injected_test_home__')
|
|
|
|
expect(electronHostDiagnosticsFile({}, isolatedHome)).toBe(
|
|
path.join(isolatedHome, '.claude', 'cc-haha', 'diagnostics', 'electron-host.log'),
|
|
)
|
|
})
|
|
|
|
it('maps host platform to existing sidecar target triples', () => {
|
|
expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin')
|
|
expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin')
|
|
expect(resolveHostTriple('win32', 'x64')).toBe('x86_64-pc-windows-msvc')
|
|
expect(resolveHostTriple('win32', 'arm64')).toBe('aarch64-pc-windows-msvc')
|
|
expect(resolveHostTriple('linux', 'x64')).toBe('x86_64-unknown-linux-gnu')
|
|
expect(resolveHostTriple('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu')
|
|
})
|
|
|
|
it('builds server sidecar args without changing the REST/WebSocket boundary', () => {
|
|
const plan = createServerPlan({
|
|
desktopRoot: '/app/desktop',
|
|
appRoot: '/app',
|
|
port: 49321,
|
|
env: {},
|
|
})
|
|
|
|
expect(plan.args).toEqual([
|
|
'server',
|
|
'--app-root',
|
|
'/app',
|
|
'--host',
|
|
'0.0.0.0',
|
|
'--port',
|
|
'49321',
|
|
])
|
|
expect(plan.env.CLAUDE_H5_AUTO_PUBLIC_URL).toBe('1')
|
|
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(path.join('/app/desktop', 'dist'))
|
|
})
|
|
|
|
it('can keep sidecar binaries and H5 assets unpacked while pointing app-root at app.asar', () => {
|
|
const resourcesRoot = path.resolve(path.sep, 'Applications', 'App.app', 'Contents', 'Resources')
|
|
const desktopRoot = path.join(resourcesRoot, 'app.asar.unpacked')
|
|
const appRoot = path.join(resourcesRoot, 'app.asar')
|
|
const h5DistDir = path.join(desktopRoot, 'dist')
|
|
const plan = createServerPlan({
|
|
desktopRoot,
|
|
appRoot,
|
|
h5DistDir,
|
|
port: 49321,
|
|
env: {},
|
|
})
|
|
|
|
expect(plan.command).toContain(path.join(desktopRoot, 'src-tauri', 'binaries', 'claude-sidecar-'))
|
|
expect(plan.args).toContain(appRoot)
|
|
expect(plan.env.CLAUDE_H5_DIST_DIR).toBe(h5DistDir)
|
|
})
|
|
|
|
it('passes the packaged ripgrep path to the server and its CLI children', () => {
|
|
const desktopRoot = mkdtempSync(path.join(tmpdir(), 'cc-haha-ripgrep-plan-'))
|
|
try {
|
|
const bundledRipgrep = resolveBundledRipgrepExecutable(desktopRoot)
|
|
mkdirSync(path.dirname(bundledRipgrep), { recursive: true })
|
|
writeFileSync(bundledRipgrep, 'fixture')
|
|
|
|
const plan = createServerPlan({
|
|
desktopRoot,
|
|
appRoot: '/app',
|
|
port: 49321,
|
|
env: {},
|
|
})
|
|
|
|
expect(plan.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep)
|
|
const pathValue = Object.entries(plan.env)
|
|
.find(([key]) => key.toLowerCase() === 'path')?.[1]
|
|
expect(pathValue?.split(path.delimiter)).toContain(
|
|
path.dirname(bundledRipgrep),
|
|
)
|
|
|
|
const adapter = createAdapterPlan({
|
|
desktopRoot,
|
|
appRoot: '/app',
|
|
serverUrl: 'http://127.0.0.1:49321',
|
|
flag: '--telegram',
|
|
env: {},
|
|
})
|
|
expect(adapter.env[RIPGREP_PATH_ENV]).toBe(bundledRipgrep)
|
|
} finally {
|
|
rmSync(desktopRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('preserves an explicit ripgrep override', () => {
|
|
const customDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-custom-ripgrep-'))
|
|
try {
|
|
const customRipgrep = path.join(customDir, 'rg')
|
|
writeFileSync(customRipgrep, 'fixture')
|
|
const plan = createServerPlan({
|
|
desktopRoot: '/app/desktop',
|
|
appRoot: '/app',
|
|
port: 49321,
|
|
env: { PATH: '/usr/bin', [RIPGREP_PATH_ENV]: customRipgrep },
|
|
})
|
|
|
|
expect(plan.env[RIPGREP_PATH_ENV]).toBe(customRipgrep)
|
|
expect(plan.env.PATH?.split(path.delimiter)).toContain(customDir)
|
|
} finally {
|
|
rmSync(customDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('passes portable config and adapter server URL through the sidecar env', () => {
|
|
const configDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-config-'))
|
|
try {
|
|
const env = buildSidecarEnv({ CLAUDE_CONFIG_DIR: configDir }, '/app/dist')
|
|
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir)
|
|
expect(env.XDG_CACHE_HOME).toBe(path.join(configDir, 'Cache'))
|
|
|
|
const adapter = createAdapterPlan({
|
|
desktopRoot: '/app/desktop',
|
|
appRoot: '/app',
|
|
serverUrl: 'http://127.0.0.1:4567',
|
|
flag: '--telegram',
|
|
env: { CLAUDE_CONFIG_DIR: configDir },
|
|
})
|
|
expect(adapter.env.ADAPTER_SERVER_URL).toBe('ws://127.0.0.1:4567')
|
|
expect(adapter.args).toEqual(['adapters', '--app-root', '/app', '--telegram'])
|
|
|
|
const whatsappAdapter = createAdapterPlan({
|
|
desktopRoot: '/app/desktop',
|
|
appRoot: '/app',
|
|
serverUrl: 'http://127.0.0.1:4567',
|
|
flag: '--whatsapp',
|
|
env: { CLAUDE_CONFIG_DIR: configDir },
|
|
})
|
|
expect(whatsappAdapter.args).toEqual(['adapters', '--app-root', '/app', '--whatsapp'])
|
|
} finally {
|
|
rmSync(configDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// The flag list is the contract between this planner, the launcher's ADAPTERS
|
|
// table and the runtime's spawn loop; a platform missing from any one of them
|
|
// simply never starts.
|
|
it('plans a sidecar for every supported adapter flag', () => {
|
|
expect(ADAPTER_FLAGS).toEqual([
|
|
'--feishu',
|
|
'--telegram',
|
|
'--wechat',
|
|
'--dingtalk',
|
|
'--whatsapp',
|
|
'--wecom',
|
|
'--qq',
|
|
'--slack',
|
|
])
|
|
|
|
for (const flag of ADAPTER_FLAGS) {
|
|
const plan = createAdapterPlan({
|
|
desktopRoot: '/app/desktop',
|
|
appRoot: '/app',
|
|
serverUrl: 'http://127.0.0.1:4567',
|
|
flag,
|
|
env: {},
|
|
})
|
|
expect(plan.args).toEqual(['adapters', '--app-root', '/app', flag])
|
|
}
|
|
})
|
|
|
|
it('isolates the server from inherited proxy env and exposes only the dynamic bridge URL', () => {
|
|
const baseEnv = {
|
|
HTTP_PROXY: 'http://stale.example:8080',
|
|
HTTPS_PROXY: 'http://stale.example:8080',
|
|
http_proxy: 'http://stale.example:8080',
|
|
https_proxy: 'http://stale.example:8080',
|
|
ALL_PROXY: 'socks5://stale.example:1080',
|
|
all_proxy: 'socks5://stale.example:1080',
|
|
NO_PROXY: '.corp.local',
|
|
}
|
|
const bridgeUrl = 'http://127.0.0.1:49123'
|
|
const serverEnv = withSystemProxyBridgeEnv(baseEnv, bridgeUrl)
|
|
|
|
expect(serverEnv[SYSTEM_PROXY_BRIDGE_ENV]).toBe(bridgeUrl)
|
|
expect(serverEnv.HTTP_PROXY).toBeUndefined()
|
|
expect(serverEnv.HTTPS_PROXY).toBeUndefined()
|
|
expect(serverEnv.http_proxy).toBeUndefined()
|
|
expect(serverEnv.https_proxy).toBeUndefined()
|
|
expect(serverEnv.ALL_PROXY).toBeUndefined()
|
|
expect(serverEnv.all_proxy).toBeUndefined()
|
|
expect(serverEnv.NO_PROXY).toBe('.corp.local,localhost,127.0.0.1,::1')
|
|
expect(clearProxyEnv(baseEnv).HTTP_PROXY).toBeUndefined()
|
|
})
|
|
|
|
it('exposes a sanitized system proxy failure without leaving a direct-fallback proxy env', () => {
|
|
const env = withSystemProxyErrorEnv({
|
|
HTTP_PROXY: 'http://stale.example:8080',
|
|
HTTPS_PROXY: 'http://stale.example:8080',
|
|
ALL_PROXY: 'socks5://stale.example:1080',
|
|
[SYSTEM_PROXY_BRIDGE_ENV]: 'http://127.0.0.1:49123',
|
|
}, new Error('bridge failed for https://user:password@proxy.example/path with sk-secret12345678'))
|
|
|
|
expect(env.HTTP_PROXY).toBeUndefined()
|
|
expect(env.HTTPS_PROXY).toBeUndefined()
|
|
expect(env.ALL_PROXY).toBeUndefined()
|
|
expect(env[SYSTEM_PROXY_BRIDGE_ENV]).toBeUndefined()
|
|
expect(env[SYSTEM_PROXY_ERROR_ENV]).toContain('System proxy bridge unavailable: bridge failed')
|
|
expect(env[SYSTEM_PROXY_ERROR_ENV]).toContain('https://[REDACTED]@proxy.example/path')
|
|
expect(env[SYSTEM_PROXY_ERROR_ENV]).not.toContain('password')
|
|
expect(env[SYSTEM_PROXY_ERROR_ENV]).not.toContain('sk-secret')
|
|
})
|
|
|
|
it('routes adapter sidecars explicitly through the dynamic bridge', () => {
|
|
const bridgeUrl = 'http://127.0.0.1:49123'
|
|
const env = withAdapterProxyBridgeEnv({
|
|
HTTPS_PROXY: 'http://stale.example:8080',
|
|
ALL_PROXY: 'socks5://stale.example:1080',
|
|
[SYSTEM_PROXY_BRIDGE_ENV]: bridgeUrl,
|
|
}, bridgeUrl)
|
|
|
|
expect(env.HTTP_PROXY).toBe(bridgeUrl)
|
|
expect(env.HTTPS_PROXY).toBe(bridgeUrl)
|
|
expect(env.http_proxy).toBe(bridgeUrl)
|
|
expect(env.https_proxy).toBe(bridgeUrl)
|
|
expect(env.ALL_PROXY).toBe(bridgeUrl)
|
|
expect(env.all_proxy).toBe(bridgeUrl)
|
|
expect(env.NO_PROXY).toContain('127.0.0.1')
|
|
})
|
|
|
|
it('keeps startup logs bounded', () => {
|
|
const logs: string[] = []
|
|
for (let index = 0; index < 85; index++) {
|
|
pushStartupLog(logs, `line ${index}`)
|
|
}
|
|
expect(logs).toHaveLength(80)
|
|
expect(logs[0]).toBe('line 5')
|
|
})
|
|
|
|
it('sanitizes the bounded startup tail before it reaches an error surface', () => {
|
|
const logs: string[] = []
|
|
pushStartupLog(
|
|
logs,
|
|
`Bearer startup.secret sk-proj-STARTUPSECRETVALUE https://alice:password@example.com ${homedir()}/project`,
|
|
)
|
|
|
|
expect(logs[0]).toContain('Bearer [REDACTED]')
|
|
expect(logs[0]).toContain('https://[REDACTED]@example.com/')
|
|
expect(logs[0]).toContain('[HOME]/project')
|
|
expect(logs[0]).not.toContain('startup.secret')
|
|
expect(logs[0]).not.toContain('sk-proj-STARTUPSECRETVALUE')
|
|
expect(logs[0]).not.toContain(homedir())
|
|
})
|
|
|
|
it('appends only a bounded sanitized Electron host-log tail', () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-'))
|
|
const logPath = path.join(dir, 'electron-host.log')
|
|
const homeDir = path.join(dir, 'private-home')
|
|
try {
|
|
for (let index = 0; index < HOST_DIAGNOSTICS_LINE_LIMIT + 5; index++) {
|
|
appendHostDiagnostic(logPath, `line ${index}`, { homeDir })
|
|
}
|
|
appendHostDiagnostic(
|
|
logPath,
|
|
`Authorization: Bearer bearer.secret api_key=sk-ant-api03-PRIVATE ANTHROPIC_API_KEY=anthropic-secret OPENAI_API_KEY="openai-secret" MINIMAX_AUTH_TOKEN='minimax-secret' https://alice:password@example.com/private ${homeDir}/project`,
|
|
{ homeDir },
|
|
)
|
|
|
|
const contents = readFileSync(logPath, 'utf-8')
|
|
const lines = contents.trimEnd().split('\n')
|
|
expect(contents).toContain('Bearer [REDACTED]')
|
|
expect(contents).toContain('api_key=[REDACTED]')
|
|
expect(contents).toContain('ANTHROPIC_API_KEY=[REDACTED]')
|
|
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
|
|
expect(contents).toContain('MINIMAX_AUTH_TOKEN=[REDACTED]')
|
|
expect(contents).toContain('https://[REDACTED]@example.com/private')
|
|
expect(contents).toContain('[HOME]/project')
|
|
expect(contents).not.toContain('bearer.secret')
|
|
expect(contents).not.toContain('sk-ant-api03-PRIVATE')
|
|
expect(contents).not.toContain('anthropic-secret')
|
|
expect(contents).not.toContain('openai-secret')
|
|
expect(contents).not.toContain('minimax-secret')
|
|
expect(contents).not.toContain('alice:password')
|
|
expect(contents).not.toContain(homeDir)
|
|
expect(lines).toHaveLength(HOST_DIAGNOSTICS_LINE_LIMIT)
|
|
expect(lines[0]).toBe('line 6')
|
|
if (process.platform !== 'win32') {
|
|
expect(statSync(logPath).mode & 0o777).toBe(0o600)
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('creates the Electron diagnostics directory with private permissions', () => {
|
|
if (process.platform === 'win32') return
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-mode-'))
|
|
const diagnosticsDir = path.join(dir, 'cc-haha', 'diagnostics')
|
|
const logPath = path.join(diagnosticsDir, 'electron-host.log')
|
|
try {
|
|
appendHostDiagnostic(logPath, 'private mode probe')
|
|
|
|
expect(statSync(diagnosticsDir).mode & 0o777).toBe(0o700)
|
|
expect(statSync(logPath).mode & 0o777).toBe(0o600)
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects a symlinked Electron diagnostics directory without changing its target', () => {
|
|
if (process.platform === 'win32') return
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-symlink-dir-'))
|
|
const diagnosticsDir = path.join(dir, 'cc-haha', 'diagnostics')
|
|
const unrelatedDir = path.join(dir, 'unrelated')
|
|
const unrelatedLog = path.join(unrelatedDir, 'electron-host.log')
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
mkdirSync(path.dirname(diagnosticsDir), { recursive: true })
|
|
mkdirSync(unrelatedDir, { mode: 0o755 })
|
|
writeFileSync(unrelatedLog, 'unrelated\n', { mode: 0o644 })
|
|
symlinkSync(unrelatedDir, diagnosticsDir, 'dir')
|
|
|
|
appendHostDiagnostic(path.join(diagnosticsDir, 'electron-host.log'), 'must not escape')
|
|
|
|
expect(statSync(unrelatedDir).mode & 0o777).toBe(0o755)
|
|
expect(statSync(unrelatedLog).mode & 0o777).toBe(0o644)
|
|
expect(readFileSync(unrelatedLog, 'utf-8')).toBe('unrelated\n')
|
|
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects an ancestor symlink before creating Electron diagnostics outside the config root', () => {
|
|
if (process.platform === 'win32') return
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-symlink-parent-'))
|
|
const configDir = path.join(dir, 'config')
|
|
const unrelatedDir = path.join(dir, 'unrelated')
|
|
const diagnosticsDir = path.join(configDir, 'cc-haha', 'diagnostics')
|
|
const unrelatedDiagnosticsDir = path.join(unrelatedDir, 'diagnostics')
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
mkdirSync(configDir)
|
|
mkdirSync(unrelatedDiagnosticsDir, { recursive: true, mode: 0o755 })
|
|
symlinkSync(unrelatedDir, path.join(configDir, 'cc-haha'), 'dir')
|
|
|
|
appendHostDiagnostic(path.join(diagnosticsDir, 'electron-host.log'), 'must not escape')
|
|
|
|
expect(statSync(unrelatedDiagnosticsDir).mode & 0o777).toBe(0o755)
|
|
expect(existsSync(path.join(unrelatedDiagnosticsDir, 'electron-host.log'))).toBe(false)
|
|
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects a symlinked Electron diagnostics file without copying its target', () => {
|
|
if (process.platform === 'win32') return
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-symlink-file-'))
|
|
const diagnosticsDir = path.join(dir, 'cc-haha', 'diagnostics')
|
|
const logPath = path.join(diagnosticsDir, 'electron-host.log')
|
|
const unrelatedLog = path.join(dir, 'unrelated.log')
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
mkdirSync(diagnosticsDir, { recursive: true })
|
|
writeFileSync(unrelatedLog, 'PRIVATE_UNRELATED_CONTENT\n', { mode: 0o644 })
|
|
chmodSync(unrelatedLog, 0o644)
|
|
symlinkSync(unrelatedLog, logPath, 'file')
|
|
|
|
appendHostDiagnostic(logPath, 'must not copy target')
|
|
|
|
expect(lstatSync(logPath).isSymbolicLink()).toBe(true)
|
|
expect(statSync(unrelatedLog).mode & 0o777).toBe(0o644)
|
|
expect(readFileSync(unrelatedLog, 'utf-8')).toBe('PRIVATE_UNRELATED_CONTENT\n')
|
|
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('bounds and re-sanitizes an oversized pre-existing host diagnostics file', () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-existing-'))
|
|
const logPath = path.join(dir, 'electron-host.log')
|
|
const homeDir = path.join(dir, 'private-home')
|
|
try {
|
|
writeFileSync(
|
|
logPath,
|
|
`${'oversized-old-data '.repeat(HOST_DIAGNOSTICS_BYTE_LIMIT)}\nOPENAI_API_KEY=old-secret ${homeDir}/private\n`,
|
|
'utf-8',
|
|
)
|
|
|
|
appendHostDiagnostic(logPath, 'latest safe diagnostic', { homeDir })
|
|
|
|
const contents = readFileSync(logPath, 'utf-8')
|
|
expect(statSync(logPath).size).toBeLessThanOrEqual(HOST_DIAGNOSTICS_BYTE_LIMIT)
|
|
expect(contents.trimEnd().split('\n').length).toBeLessThanOrEqual(HOST_DIAGNOSTICS_LINE_LIMIT)
|
|
expect(contents).toContain('latest safe diagnostic')
|
|
expect(contents).toContain('OPENAI_API_KEY=[REDACTED]')
|
|
expect(contents).not.toContain('old-secret')
|
|
expect(contents).not.toContain(homeDir)
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('does not crash Electron when the host diagnostics destination cannot be written', () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-failure-'))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
expect(() => appendHostDiagnostic(dir, 'sidecar failed')).not.toThrow()
|
|
expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics')
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('maps http urls to adapter websocket urls', () => {
|
|
expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456')
|
|
expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com')
|
|
})
|
|
|
|
it('kills non-Windows sidecars with a signal', () => {
|
|
const child = fakeChild()
|
|
const spawnAsync = vi.fn()
|
|
const spawnSyncFn = vi.fn()
|
|
killSidecar(child, false, { platform: 'darwin', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never })
|
|
expect(child.kill).toHaveBeenCalledTimes(1)
|
|
expect(spawnAsync).not.toHaveBeenCalled()
|
|
expect(spawnSyncFn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses async taskkill on Windows by default', () => {
|
|
const child = fakeChild(777)
|
|
const taskkill = new EventEmitter()
|
|
const spawnAsync = vi.fn(() => taskkill)
|
|
const spawnSyncFn = vi.fn()
|
|
killSidecar(child, false, {
|
|
platform: 'win32',
|
|
env: { SystemRoot: 'C:\\Windows' },
|
|
spawnAsync: spawnAsync as never,
|
|
spawnSyncFn: spawnSyncFn as never,
|
|
})
|
|
expect(spawnAsync).toHaveBeenCalledWith('C:\\Windows\\System32\\taskkill.exe', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true })
|
|
expect(spawnSyncFn).not.toHaveBeenCalled()
|
|
expect(child.kill).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back without crashing when async taskkill is unavailable on Windows', () => {
|
|
const child = fakeChild(777)
|
|
const taskkill = new EventEmitter()
|
|
const spawnAsync = vi.fn(() => taskkill)
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
try {
|
|
killSidecar(child, false, {
|
|
platform: 'win32',
|
|
env: { SystemRoot: 'C:\\Windows' },
|
|
spawnAsync: spawnAsync as never,
|
|
})
|
|
|
|
const error = Object.assign(new Error('spawn taskkill ENOENT'), { code: 'ENOENT' })
|
|
expect(() => taskkill.emit('error', error)).not.toThrow()
|
|
expect(child.kill).toHaveBeenCalledTimes(1)
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[desktop] taskkill failed; falling back to direct sidecar termination',
|
|
error,
|
|
)
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('uses synchronous taskkill on Windows during shutdown to avoid orphaned sidecars', () => {
|
|
const child = fakeChild(777)
|
|
const spawnAsync = vi.fn()
|
|
const spawnSyncFn = vi.fn(() => ({ error: undefined }))
|
|
killSidecar(child, true, {
|
|
platform: 'win32',
|
|
env: { SystemRoot: 'C:\\Windows' },
|
|
spawnAsync: spawnAsync as never,
|
|
spawnSyncFn: spawnSyncFn as never,
|
|
})
|
|
expect(spawnSyncFn).toHaveBeenCalledWith('C:\\Windows\\System32\\taskkill.exe', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true })
|
|
expect(spawnAsync).not.toHaveBeenCalled()
|
|
expect(child.kill).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back when synchronous taskkill is unavailable on Windows', () => {
|
|
const child = fakeChild(777)
|
|
const error = Object.assign(new Error('spawnSync taskkill ENOENT'), { code: 'ENOENT' })
|
|
const spawnSyncFn = vi.fn(() => ({ error }))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
try {
|
|
killSidecar(child, true, {
|
|
platform: 'win32',
|
|
env: { SystemRoot: 'C:\\Windows' },
|
|
spawnSyncFn: spawnSyncFn as never,
|
|
})
|
|
|
|
expect(child.kill).toHaveBeenCalledTimes(1)
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[desktop] taskkill failed; falling back to direct sidecar termination',
|
|
error,
|
|
)
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('resolves taskkill from Windows system directories without relying on PATH', () => {
|
|
expect(resolveWindowsTaskkillExecutable({ SYSTEMROOT: 'D:\\Windows' }))
|
|
.toBe('D:\\Windows\\System32\\taskkill.exe')
|
|
expect(resolveWindowsTaskkillExecutable({ windir: 'E:\\WinDir' }))
|
|
.toBe('E:\\WinDir\\System32\\taskkill.exe')
|
|
expect(resolveWindowsTaskkillExecutable({}))
|
|
.toBe('taskkill.exe')
|
|
})
|
|
|
|
it('hides Windows console windows when launching sidecars', () => {
|
|
const spawned = {} as SidecarChild
|
|
const spawnFn = vi.fn(() => spawned)
|
|
const existsSyncFn = vi.fn(() => true)
|
|
const plan = {
|
|
command: '/app/desktop/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe',
|
|
args: ['server', '--port', '49321'],
|
|
env: { CLAUDE_H5_AUTO_PUBLIC_URL: '1' },
|
|
}
|
|
|
|
expect(spawnSidecar(plan, { existsSyncFn, spawnFn: spawnFn as never })).toBe(spawned)
|
|
expect(existsSyncFn).toHaveBeenCalledWith(plan.command)
|
|
expect(spawnFn).toHaveBeenCalledWith(plan.command, plan.args, {
|
|
env: plan.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
})
|
|
})
|
|
|
|
it('explains a sidecar that exists but cannot be executed after a broken install', () => {
|
|
const plan = {
|
|
command: 'C:\\Program Files\\Claude Code Haha\\resources\\claude-sidecar-x86_64-pc-windows-msvc.exe',
|
|
args: ['server', '--port', '49321'],
|
|
env: {},
|
|
}
|
|
const badExe = Object.assign(new Error('spawn EFTYPE'), { code: 'EFTYPE', errno: -4028, syscall: 'spawn' })
|
|
const spawnFn = vi.fn(() => { throw badExe })
|
|
|
|
let thrown: unknown
|
|
try {
|
|
spawnSidecar(plan, { existsSyncFn: () => true, spawnFn: spawnFn as never })
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
expect((thrown as Error).message).toContain('Electron sidecar could not be executed (EFTYPE)')
|
|
expect((thrown as Error).message).toContain(plan.command)
|
|
expect((thrown as Error).message).toContain('Reinstall Claude Code Haha with the full installer')
|
|
expect((thrown as Error).cause).toBe(badExe)
|
|
expect(formatStartupError((thrown as Error).message, [])).not.toContain('timeout')
|
|
})
|
|
|
|
it('rethrows unrelated sidecar spawn failures unchanged', () => {
|
|
const plan = { command: '/app/claude-sidecar', args: ['server'], env: {} }
|
|
const failure = Object.assign(new Error('spawn ENOMEM'), { code: 'ENOMEM' })
|
|
|
|
expect(() => spawnSidecar(plan, {
|
|
existsSyncFn: () => true,
|
|
spawnFn: (() => { throw failure }) as never,
|
|
})).toThrow(failure)
|
|
})
|
|
|
|
it('forwards a PowerShell shell choice to the sidecar only on Windows', () => {
|
|
expect(windowsPowerShellOverride('pwsh.exe', 'win32')).toBe('pwsh.exe')
|
|
expect(windowsPowerShellOverride('powershell.exe', 'win32')).toBe('powershell.exe')
|
|
expect(windowsPowerShellOverride('C:\\tools\\PowerShell\\pwsh.exe', 'win32')).toBe('C:\\tools\\PowerShell\\pwsh.exe')
|
|
// non-PowerShell selections must not be reported as a PowerShell override
|
|
expect(windowsPowerShellOverride('cmd.exe', 'win32')).toBeNull()
|
|
expect(windowsPowerShellOverride('C:\\bin\\bash.exe', 'win32')).toBeNull()
|
|
expect(windowsPowerShellOverride(null, 'win32')).toBeNull()
|
|
// never applies off Windows
|
|
expect(windowsPowerShellOverride('pwsh', 'darwin')).toBeNull()
|
|
expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull()
|
|
})
|
|
|
|
it('parses only browser-safe in-range integer h5Access.fixedPort values', () => {
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670)
|
|
for (const port of [
|
|
1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000,
|
|
6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
|
|
]) {
|
|
expect(parseH5FixedPort(`{"h5Access":{"fixedPort":${port}}}`)).toBeNull()
|
|
}
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":5062}}')).toBe(5062)
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull()
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull()
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull()
|
|
expect(parseH5FixedPort('{"h5Access":{"fixedPort":null}}')).toBeNull()
|
|
expect(parseH5FixedPort('{"h5Access":{}}')).toBeNull()
|
|
expect(parseH5FixedPort('{}')).toBeNull()
|
|
expect(parseH5FixedPort('not json')).toBeNull()
|
|
})
|
|
|
|
it('persists and prioritizes preferred server ports from the config dir', () => {
|
|
const configDir = mkdtempSync(path.join(tmpdir(), 'cchh-server-state-'))
|
|
const env = { CLAUDE_CONFIG_DIR: configDir } as NodeJS.ProcessEnv
|
|
try {
|
|
// Nothing stored yet: no preferred ports.
|
|
expect(preferredServerPorts(env)).toEqual([])
|
|
|
|
// A browser-blocked port persisted by an older build is ignored.
|
|
writeFileSync(
|
|
path.join(configDir, SERVER_STATE_FILE),
|
|
JSON.stringify({ lastPort: 5061 }),
|
|
'utf-8',
|
|
)
|
|
expect(readLastServerPort(env)).toBeNull()
|
|
expect(preferredServerPorts(env)).toEqual([])
|
|
|
|
// Sticky port from the previous run.
|
|
writeLastServerPort(50123, env)
|
|
expect(readLastServerPort(env)).toBe(50123)
|
|
expect(preferredServerPorts(env)).toEqual([50123])
|
|
|
|
// An explicit fixed port wins over the sticky port.
|
|
mkdirSync(path.join(configDir, 'cc-haha'), { recursive: true })
|
|
writeFileSync(
|
|
path.join(configDir, 'cc-haha', 'settings.json'),
|
|
JSON.stringify({ h5Access: { fixedPort: 28670 } }),
|
|
'utf-8',
|
|
)
|
|
expect(readH5FixedPort(env)).toBe(28670)
|
|
expect(preferredServerPorts(env)).toEqual([28670, 50123])
|
|
|
|
// Identical fixed and sticky ports are not duplicated.
|
|
writeLastServerPort(28670, env)
|
|
expect(preferredServerPorts(env)).toEqual([28670])
|
|
} finally {
|
|
rmSync(configDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('reserves a free preferred port and falls back when it is taken', async () => {
|
|
// Reserve a random free port, verify preference picks it while free.
|
|
const freePort = await reserveLocalPort('127.0.0.1')
|
|
await expect(reserveServerPort('127.0.0.1', [freePort])).resolves.toBe(freePort)
|
|
|
|
// Occupy it and verify the fallback hands out a different port.
|
|
const blocker = net.createServer()
|
|
await new Promise<void>((resolve, reject) => {
|
|
blocker.once('error', reject)
|
|
blocker.listen(freePort, '127.0.0.1', () => resolve())
|
|
})
|
|
try {
|
|
const fallback = await reserveServerPort('127.0.0.1', [freePort])
|
|
expect(fallback).not.toBe(freePort)
|
|
} finally {
|
|
await new Promise<void>(resolve => blocker.close(() => resolve()))
|
|
}
|
|
|
|
// Invalid entries are skipped without throwing.
|
|
await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0)
|
|
})
|
|
|
|
it('skips preferred ports blocked by browser fetch', async () => {
|
|
const port = await reserveServerPort('127.0.0.1', [5061])
|
|
expect(port).not.toBe(5061)
|
|
})
|
|
|
|
it('retries when the OS assigns a browser-blocked random port', async () => {
|
|
const reserveCandidate = vi.fn()
|
|
.mockResolvedValueOnce(5061)
|
|
.mockResolvedValueOnce(5062)
|
|
|
|
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate })).resolves.toBe(5062)
|
|
expect(reserveCandidate).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('stops retrying after repeated browser-blocked random ports', async () => {
|
|
const reserveCandidate = vi.fn().mockResolvedValue(5061)
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
|
|
try {
|
|
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate }))
|
|
.rejects.toThrow('Could not reserve a browser-safe local port')
|
|
expect(reserveCandidate).toHaveBeenCalledTimes(128)
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('propagates random port reservation errors', async () => {
|
|
const reserveCandidate = vi.fn().mockRejectedValue(new Error('bind failed'))
|
|
|
|
await expect(reserveLocalPort('127.0.0.1', { reserveCandidate }))
|
|
.rejects.toThrow('bind failed')
|
|
expect(reserveCandidate).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not treat a raw TCP accept as server readiness without healthy /health', async () => {
|
|
const server = http.createServer((_request, response) => {
|
|
response.writeHead(503, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({ status: 'starting' }))
|
|
})
|
|
const port = await listen(server)
|
|
|
|
try {
|
|
await expect(waitForServer('127.0.0.1', port, 300)).rejects.toThrow(
|
|
/desktop server did not report healthy at http:\/\/127\.0\.0\.1:\d+\/health/,
|
|
)
|
|
} finally {
|
|
await close(server)
|
|
}
|
|
})
|
|
|
|
it('isolates health probes from the shared HTTP connection pool', async () => {
|
|
const connections = new Set<number>()
|
|
const connectionHeaders: string[] = []
|
|
const server = http.createServer((request, response) => {
|
|
connections.add(request.socket.remotePort!)
|
|
connectionHeaders.push(request.headers.connection ?? '')
|
|
response.writeHead(200, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({ status: 'ok' }))
|
|
})
|
|
const port = await listen(server)
|
|
|
|
try {
|
|
await waitForServer('127.0.0.1', port, 1_000)
|
|
await waitForServer('127.0.0.1', port, 1_000)
|
|
expect(connections.size).toBe(2)
|
|
expect(connectionHeaders).toEqual(['close', 'close'])
|
|
} finally {
|
|
await close(server)
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* The adapter flag list lives in four places, in three languages: this module,
|
|
* the launcher's `ADAPTERS` table, the Electron spawn loop and the Tauri shell.
|
|
*
|
|
* The first two are type-checked against `AdapterFlag`, and the spawn loop is
|
|
* covered by the count assertions in `serverRuntime.test.ts`. The launcher
|
|
* table is only exercised by the opt-in compiled smoke, and `lib.rs` by nothing
|
|
* at all — a platform missing from either one simply never starts, with no
|
|
* failing test anywhere. Reading the sources is crude, but it is the only
|
|
* check that spans a TypeScript entrypoint and a Rust file.
|
|
*/
|
|
describe('adapter flag parity across the launch path', () => {
|
|
const repoRoot = path.resolve(import.meta.dirname, '../../..')
|
|
|
|
/** TypeScript quotes with `'`, Rust with `"`. */
|
|
const quoted = (source: string, flag: string) =>
|
|
source.includes(`'${flag}'`) || source.includes(`"${flag}"`)
|
|
|
|
it.each([
|
|
['the sidecar launcher table', 'desktop/sidecars/claude-sidecar.ts'],
|
|
['the Tauri shell spawn list', 'desktop/src-tauri/src/lib.rs'],
|
|
['the Electron spawn loop', 'desktop/electron/services/serverRuntime.ts'],
|
|
])('lists every adapter flag in %s', (_label, relativePath) => {
|
|
const source = readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
|
|
|
const missing = ADAPTER_FLAGS.filter((flag) => !quoted(source, flag))
|
|
expect(missing).toEqual([])
|
|
})
|
|
})
|