diff --git a/desktop/electron/services/terminal.test.ts b/desktop/electron/services/terminal.test.ts index b3cceb26..ce385944 100644 --- a/desktop/electron/services/terminal.test.ts +++ b/desktop/electron/services/terminal.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -54,6 +55,7 @@ class FakePty implements TerminalPtyProcess { } const tempDirs: string[] = [] +const itOnDarwin = process.platform === 'darwin' ? it : it.skip function tempDir() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-terminal-')) @@ -168,6 +170,25 @@ describe('Electron terminal service', () => { 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() diff --git a/desktop/electron/services/terminal.ts b/desktop/electron/services/terminal.ts index d7e07981..699fcdae 100644 --- a/desktop/electron/services/terminal.ts +++ b/desktop/electron/services/terminal.ts @@ -11,6 +11,7 @@ const TERMINAL_CONFIG_FILE = 'terminal-config.json' const MIN_TERMINAL_COLS = 20 const MIN_TERMINAL_ROWS = 8 const NODE_PTY_MANIFEST_FILE = '.cc-haha-node-pty-manifest.json' +const MACOS_DOWNLOAD_XATTRS = ['com.apple.quarantine', 'com.apple.provenance'] export type TerminalSpawnInput = { cols?: number @@ -367,6 +368,47 @@ function chmodNodePtyDirectories(moduleDir: string): void { } } +function stripMacosDownloadAttributes(moduleDir: string): void { + if (process.platform !== 'darwin') return + + for (const attr of MACOS_DOWNLOAD_XATTRS) { + try { + execFileSync('/usr/bin/xattr', ['-dr', attr, moduleDir], { stdio: 'ignore' }) + } catch { + // Best effort: the attribute may be absent, but stale quarantine blocks copied .node files. + } + } + + for (const filePath of walkNodePtyFiles(moduleDir)) { + let originalMode: number | null = null + try { + const stat = fs.statSync(filePath) + const mode = stat.mode & 0o777 + if ((mode & 0o200) === 0) { + originalMode = mode + fs.chmodSync(filePath, mode | 0o200) + } + for (const attr of MACOS_DOWNLOAD_XATTRS) { + try { + execFileSync('/usr/bin/xattr', ['-d', attr, filePath], { stdio: 'ignore' }) + } catch { + // Best effort: only files that still carry download xattrs need this fallback. + } + } + } catch { + // Best effort: a partially rebuilt cache will be removed and copied again later. + } finally { + if (originalMode != null) { + try { + fs.chmodSync(filePath, originalMode) + } catch { + // Best effort: helper executable bits are restored separately before loading node-pty. + } + } + } + } +} + function isNodePtyCacheCurrent(sourceManifest: NodePtyIntegrityManifest, cacheDir: string): boolean { const cacheManifest = readNodePtyManifest(cacheDir) if (!cacheManifest || !manifestsEqual(sourceManifest, cacheManifest)) return false @@ -385,9 +427,11 @@ export function prepareNodePtyRuntime(sourceDir: string, cacheDir: string): stri const sourceManifest = buildNodePtyManifest(sourceDir) if (preparedNodePtyDirs.has(cacheDir) && isNodePtyCacheCurrent(sourceManifest, cacheDir)) { + stripMacosDownloadAttributes(cacheDir) return cacheDir } if (!preparedNodePtyDirs.has(cacheDir) && isNodePtyCacheCurrent(sourceManifest, cacheDir)) { + stripMacosDownloadAttributes(cacheDir) preparedNodePtyDirs.add(cacheDir) return cacheDir } @@ -396,6 +440,7 @@ export function prepareNodePtyRuntime(sourceDir: string, cacheDir: string): stri fs.mkdirSync(path.dirname(cacheDir), { recursive: true, mode: 0o700 }) fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 }) fs.cpSync(sourceDir, cacheDir, { recursive: true }) + stripMacosDownloadAttributes(cacheDir) ensureNodePtyHelpersExecutable(cacheDir) chmodNodePtyDirectories(cacheDir) if (!manifestsEqual(sourceManifest, buildNodePtyManifest(cacheDir))) {