mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Remove macOS download quarantine/provenance attributes from the packaged node-pty runtime cache before loading the native module. Existing matching caches are repaired before require, and read-only helper permissions are restored after cleanup.\n\nTested: bun test desktop/electron/services/terminal.test.ts\nTested: cd desktop && bun run build:electron\nTested: git diff --check\nTested: prepared the installed 0.4.3 node-pty cache and required it with Electron's Node runtime\nScope-risk: narrow\nConfidence: high
257 lines
9.7 KiB
TypeScript
257 lines
9.7 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
|
|
import {
|
|
ElectronTerminalService,
|
|
defaultShell,
|
|
desktopTerminalSettingsPath,
|
|
ensureUtf8Locale,
|
|
normalizeTerminalBashPath,
|
|
parseEnvBlock,
|
|
prepareNodePtyRuntime,
|
|
resolveDesktopTerminalShell,
|
|
terminalConfigPath,
|
|
type TerminalPtyFactory,
|
|
type TerminalPtyProcess,
|
|
} from './terminal'
|
|
|
|
class FakePty implements TerminalPtyProcess {
|
|
writes: string[] = []
|
|
resizes: Array<{ cols: number, rows: number }> = []
|
|
killed = false
|
|
private dataHandler: ((data: string) => void) | null = null
|
|
private exitHandler: ((event: { exitCode: number, signal?: number | string | null }) => void) | null = null
|
|
|
|
write(data: string) {
|
|
this.writes.push(data)
|
|
}
|
|
|
|
resize(cols: number, rows: number) {
|
|
this.resizes.push({ cols, rows })
|
|
}
|
|
|
|
kill() {
|
|
this.killed = true
|
|
}
|
|
|
|
onData(handler: (data: string) => void) {
|
|
this.dataHandler = handler
|
|
}
|
|
|
|
onExit(handler: (event: { exitCode: number, signal?: number | string | null }) => void) {
|
|
this.exitHandler = handler
|
|
}
|
|
|
|
emitData(data: string) {
|
|
this.dataHandler?.(data)
|
|
}
|
|
|
|
emitExit(event: { exitCode: number, signal?: number | string | null }) {
|
|
this.exitHandler?.(event)
|
|
}
|
|
}
|
|
|
|
const tempDirs: string[] = []
|
|
const itOnDarwin = process.platform === 'darwin' ? it : it.skip
|
|
|
|
function tempDir() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-terminal-'))
|
|
tempDirs.push(dir)
|
|
return dir
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('Electron terminal service', () => {
|
|
it('uses the portable terminal config path before app userData', () => {
|
|
const app = { getPath: vi.fn(() => '/app/user-data') }
|
|
|
|
expect(terminalConfigPath(app, { CLAUDE_CONFIG_DIR: '/portable' })).toBe('/portable/terminal-config.json')
|
|
expect(terminalConfigPath(app, {})).toBe('/app/user-data/terminal-config.json')
|
|
})
|
|
|
|
it('persists the legacy bash path config and validates saved paths', () => {
|
|
const dir = tempDir()
|
|
const bash = path.join(dir, 'bash.exe')
|
|
fs.writeFileSync(bash, '')
|
|
const service = new ElectronTerminalService({
|
|
env: { CLAUDE_CONFIG_DIR: dir },
|
|
isFile: filePath => filePath === bash,
|
|
})
|
|
|
|
service.setBashPath(` ${bash} `)
|
|
expect(service.getBashPath()).toBe(bash)
|
|
expect(JSON.parse(fs.readFileSync(path.join(dir, 'terminal-config.json'), 'utf8'))).toEqual({
|
|
bash_path: bash,
|
|
})
|
|
expect(() => service.setBashPath('/missing/bash')).toThrow('terminal bash path does not exist')
|
|
expect(normalizeTerminalBashPath(' ', () => false)).toBeNull()
|
|
})
|
|
|
|
it('resolves platform-specific shells from the same settings shape as Tauri', () => {
|
|
expect(resolveDesktopTerminalShell('win32', { startupShell: 'pwsh' })).toBe('pwsh.exe')
|
|
expect(resolveDesktopTerminalShell('win32', { startupShell: 'powershell' })).toBe('powershell.exe')
|
|
expect(resolveDesktopTerminalShell('win32', { startupShell: 'cmd' })).toBe('cmd.exe')
|
|
expect(resolveDesktopTerminalShell('win32', { startupShell: 'custom', customShellPath: ' C:\\Tools\\shell.exe ' })).toBe('C:\\Tools\\shell.exe')
|
|
expect(() => resolveDesktopTerminalShell('win32', { startupShell: 'custom' })).toThrow('custom terminal shell path is empty')
|
|
expect(resolveDesktopTerminalShell('darwin', { startupShell: 'pwsh' })).toBeNull()
|
|
})
|
|
|
|
it('prefers Windows custom bash when valid and falls back to COMSPEC', () => {
|
|
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\Git\\bin\\bash.exe', file => file.endsWith('bash.exe'))).toBe(
|
|
'C:\\Git\\bin\\bash.exe',
|
|
)
|
|
expect(defaultShell('win32', { COMSPEC: 'cmd.exe' }, 'C:\\missing\\bash.exe', () => false)).toBe('cmd.exe')
|
|
expect(defaultShell('linux', { SHELL: '/bin/fish' }, null, () => false)).toBe('/bin/fish')
|
|
})
|
|
|
|
it('reads desktop terminal settings from the Claude config directory', () => {
|
|
const dir = tempDir()
|
|
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true })
|
|
fs.writeFileSync(
|
|
path.join(dir, '.claude', 'settings.json'),
|
|
JSON.stringify({ desktopTerminal: { startupShell: 'cmd' } }),
|
|
)
|
|
|
|
const service = new ElectronTerminalService({
|
|
env: { HOME: dir, COMSPEC: 'powershell.exe' },
|
|
platform: 'win32',
|
|
})
|
|
|
|
expect(desktopTerminalSettingsPath({ HOME: dir })).toBe(path.join(dir, '.claude', 'settings.json'))
|
|
expect(service.resolveShell()).toBe('cmd.exe')
|
|
})
|
|
|
|
it('normalizes terminal environment data to UTF-8 locale', () => {
|
|
expect(parseEnvBlock(Buffer.from('A=1\0B=two=2\0\0'))).toEqual({ A: '1', B: 'two=2' })
|
|
expect(ensureUtf8Locale({ LANG: 'C' }, 'darwin')).toMatchObject({
|
|
LANG: 'en_US.UTF-8',
|
|
LC_CTYPE: 'en_US.UTF-8',
|
|
LC_ALL: 'en_US.UTF-8',
|
|
})
|
|
})
|
|
|
|
it('copies packaged node-pty to a writable runtime cache and restores helper executable bits', () => {
|
|
const source = tempDir()
|
|
const cache = path.join(tempDir(), 'node-pty-cache')
|
|
const helper = path.join(source, 'prebuilds', 'darwin-arm64', 'spawn-helper')
|
|
fs.mkdirSync(path.dirname(helper), { recursive: true })
|
|
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
|
|
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() {} }\n')
|
|
fs.writeFileSync(helper, 'helper')
|
|
fs.chmodSync(helper, 0o644)
|
|
|
|
expect(prepareNodePtyRuntime(source, cache)).toBe(cache)
|
|
expect(fs.existsSync(path.join(cache, 'index.js'))).toBe(true)
|
|
expect(fs.statSync(cache).mode & 0o077).toBe(0)
|
|
expect(fs.statSync(path.join(cache, 'prebuilds', 'darwin-arm64', 'spawn-helper')).mode & 0o777).toBe(0o500)
|
|
expect(fs.existsSync(path.join(cache, '.cc-haha-node-pty-manifest.json'))).toBe(true)
|
|
})
|
|
|
|
it('rebuilds the packaged node-pty runtime cache when cached files are tampered', () => {
|
|
const source = tempDir()
|
|
const cache = path.join(tempDir(), 'node-pty-cache')
|
|
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
|
|
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() { return "source" } }\n')
|
|
|
|
prepareNodePtyRuntime(source, cache)
|
|
fs.writeFileSync(path.join(cache, 'index.js'), 'module.exports = { spawn() { return "tampered" } }\n')
|
|
|
|
prepareNodePtyRuntime(source, cache)
|
|
|
|
expect(fs.readFileSync(path.join(cache, 'index.js'), 'utf8')).toBe('module.exports = { spawn() { return "source" } }\n')
|
|
})
|
|
|
|
itOnDarwin('removes stale macOS quarantine attributes from the node-pty runtime cache', () => {
|
|
const source = tempDir()
|
|
const cache = path.join(tempDir(), 'node-pty-cache')
|
|
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'index.js' }))
|
|
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = { spawn() { return "source" } }\n')
|
|
|
|
prepareNodePtyRuntime(source, cache)
|
|
|
|
const cachedEntry = path.join(cache, 'index.js')
|
|
execFileSync('/usr/bin/xattr', ['-w', 'com.apple.quarantine', '0381;00000000;Chrome;CC-HAHA-TEST', cachedEntry])
|
|
execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', cachedEntry], { stdio: 'ignore' })
|
|
fs.chmodSync(cachedEntry, 0o500)
|
|
|
|
prepareNodePtyRuntime(source, cache)
|
|
|
|
expect(() => execFileSync('/usr/bin/xattr', ['-p', 'com.apple.quarantine', cachedEntry], { stdio: 'ignore' })).toThrow()
|
|
expect(fs.statSync(cachedEntry).mode & 0o777).toBe(0o500)
|
|
})
|
|
|
|
it('spawns a PTY, forwards events, and controls the active session', async () => {
|
|
const dir = tempDir()
|
|
const fakePty = new FakePty()
|
|
const spawn = vi.fn(() => fakePty)
|
|
const sent: Array<{ channel: string, payload: unknown }> = []
|
|
const service = new ElectronTerminalService({
|
|
env: { HOME: dir, SHELL: '/bin/test-shell' },
|
|
platform: 'linux',
|
|
ptyFactory: { spawn } satisfies TerminalPtyFactory,
|
|
fileExists: filePath => filePath === '/bin/test-shell',
|
|
})
|
|
|
|
const session = await service.spawn(
|
|
{ cols: 10, rows: 4, cwd: dir },
|
|
{ send: (channel, payload) => sent.push({ channel, payload }) },
|
|
)
|
|
|
|
expect(session).toEqual({ session_id: 1, shell: '/bin/test-shell', cwd: dir })
|
|
expect(spawn).toHaveBeenCalledWith('/bin/test-shell', [], expect.objectContaining({
|
|
name: 'xterm-256color',
|
|
cols: 20,
|
|
rows: 8,
|
|
cwd: dir,
|
|
env: expect.objectContaining({
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
}),
|
|
}))
|
|
|
|
service.write(1, 'echo hello\r')
|
|
service.resize(1, 12, 6)
|
|
fakePty.emitData('hello\r\n')
|
|
fakePty.emitExit({ exitCode: 0 })
|
|
|
|
expect(fakePty.writes).toEqual(['echo hello\r'])
|
|
expect(fakePty.resizes).toEqual([{ cols: 20, rows: 8 }])
|
|
expect(sent).toEqual([
|
|
{
|
|
channel: ELECTRON_EVENT_CHANNELS.terminalOutput,
|
|
payload: { session_id: 1, data: 'hello\r\n' },
|
|
},
|
|
{
|
|
channel: ELECTRON_EVENT_CHANNELS.terminalExit,
|
|
payload: { session_id: 1, code: 0, signal: null },
|
|
},
|
|
])
|
|
expect(() => service.write(1, 'after exit')).toThrow('terminal session is not running')
|
|
})
|
|
|
|
it('kills a running PTY session without failing when the session is already gone', async () => {
|
|
const dir = tempDir()
|
|
const fakePty = new FakePty()
|
|
const service = new ElectronTerminalService({
|
|
env: { HOME: dir, SHELL: '/bin/test-shell' },
|
|
platform: 'linux',
|
|
ptyFactory: { spawn: vi.fn(() => fakePty) },
|
|
})
|
|
|
|
await service.spawn({ cols: 80, rows: 24, cwd: dir }, { send: vi.fn() })
|
|
service.kill(1)
|
|
service.kill(1)
|
|
|
|
expect(fakePty.killed).toBe(true)
|
|
})
|
|
})
|