fix(desktop): clear macOS quarantine from node-pty cache

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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-17 17:06:24 +08:00
parent 6715e75161
commit 2c1af7a843
2 changed files with 66 additions and 0 deletions

View File

@ -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()

View File

@ -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))) {