diff --git a/packages/core/src/hook/hook-manager.ts b/packages/core/src/hook/hook-manager.ts index bdbb8f64..b03a07b3 100644 --- a/packages/core/src/hook/hook-manager.ts +++ b/packages/core/src/hook/hook-manager.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import type { BridgeManager } from '../bridge/manager'; import type { PacketSink } from '../protocol/types'; import { createLogger, type Logger } from '../utils/logger'; @@ -169,7 +170,7 @@ export class HookManager { // injected without a human clicking "Load" in WebUI. Fire-and-forget: // failures are already captured inside loadInternal and surfaced via // the session's status field. - if (this.autoLoadOnDiscovery) { + if (this.autoLoadOnDiscovery && shouldAutoLoadPid(info.pid, this.log)) { void session.load().catch((err) => { this.log.warn('auto-load failed: PID=%d err=%s', info.pid, errMsg(err)); }); @@ -243,3 +244,39 @@ function defaultProcessName(): string { function errMsg(value: unknown): string { return value instanceof Error ? value.message : String(value); } + +/** + * Filter for auto-load only. Returns false for Linux Electron child + * processes (zygote/renderer/gpu/utility), which the native enumerator + * mis-classifies as "main QQ" because their cmdline contains "qq" and + * they transiently inherit wrapper.node via fork copy-on-write. + * + * Injecting into a zygote spawns the hook's resolver thread inside it; + * every renderer Electron later forks then inherits that thread + the + * partially-init'd hook state, which breaks QQ's IPC and login UI. + * + * Manual loadProcess() bypasses this gate — the operator is responsible + * for picking the right PID from WebUI. + */ +export function shouldAutoLoadPid(pid: number, log: Logger): boolean { + if (process.platform !== 'linux') return true; + // Escape hatch: operators who explicitly want auto-load on every + // enumerated PID can set this to opt out of the filter. + if (process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL === '1') return true; + + let cmdline: string; + try { + cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + } catch { + // Can't read cmdline (process gone, permission, etc.) — let the + // existing load() path handle it; if the process is dead the load + // will fail with a clear error. + return true; + } + if (cmdline.includes('--type=')) { + const type = (/--type=([^\0\s]+)/.exec(cmdline)?.[1]) ?? 'unknown'; + log.info('auto-load skip: PID=%d is an Electron child process (--type=%s)', pid, type); + return false; + } + return true; +} diff --git a/packages/core/tests/hook-manager.test.ts b/packages/core/tests/hook-manager.test.ts index d6cb7c73..87c0e15c 100644 --- a/packages/core/tests/hook-manager.test.ts +++ b/packages/core/tests/hook-manager.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'events'; -import { HookManager } from '../src/hook/hook-manager'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { HookManager, shouldAutoLoadPid } from '../src/hook/hook-manager'; import { PipeWatcher } from '../src/hook/pipe-watcher'; import type { ManualMapHandle } from '../src/hook/injector'; import type { BridgeManager } from '../src/bridge/manager'; import type { QqHookClient } from '../src/hook/qq-hook-client'; +import { createLogger } from '../src/utils/logger'; const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 }; const flush = () => new Promise(r => setImmediate(r)); @@ -102,3 +106,77 @@ describe('HookManager.autoLoadOnDiscovery', () => { ctx.manager.dispose(); }); }); + +// `shouldAutoLoadPid` reads /proc//cmdline directly, so we point +// it at a temporary directory that mimics the procfs layout. This lets +// the tests run on macOS / CI without a live Linux QQ process. +describe('shouldAutoLoadPid', () => { + let tmpProc: string; + const originalReadFileSync = fs.readFileSync; + const log = createLogger('test'); + const originalPlatform = process.platform; + const originalAutoLoadAll = process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL; + + beforeEach(() => { + tmpProc = fs.mkdtempSync(path.join(os.tmpdir(), 'hookmgr-proc-')); + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL; + // Redirect readFileSync('/proc//cmdline') to the tmp dir. + (fs.readFileSync as unknown as typeof fs.readFileSync) = ((p: string, ...rest: unknown[]) => { + const match = /^\/proc\/(\d+)\/cmdline$/.exec(p); + if (match) { + return originalReadFileSync(path.join(tmpProc, match[1], 'cmdline'), ...(rest as [BufferEncoding])); + } + return originalReadFileSync(p, ...(rest as [BufferEncoding])); + }) as typeof fs.readFileSync; + }); + + afterEach(() => { + (fs.readFileSync as unknown as typeof fs.readFileSync) = originalReadFileSync; + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + if (originalAutoLoadAll === undefined) delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL; + else process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = originalAutoLoadAll; + fs.rmSync(tmpProc, { recursive: true, force: true }); + }); + + function writeCmdline(pid: number, args: string[]): void { + const dir = path.join(tmpProc, String(pid)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'cmdline'), args.join('\0') + '\0'); + } + + it('allows the QQ main process (no --type=)', () => { + writeCmdline(50, ['qq', '--no-sandbox']); + expect(shouldAutoLoadPid(50, log)).toBe(true); + }); + + it('rejects Electron zygotes', () => { + writeCmdline(59, ['/opt/QQ/qq', '--type=zygote', '--no-zygote-sandbox', '--no-sandbox']); + expect(shouldAutoLoadPid(59, log)).toBe(false); + }); + + it('rejects renderer/gpu/utility children', () => { + writeCmdline(70, ['/opt/QQ/qq', '--type=renderer']); + writeCmdline(71, ['/opt/QQ/qq', '--type=gpu-process']); + writeCmdline(72, ['/opt/QQ/qq', '--type=utility', '--utility-sub-type=network.mojom.NetworkService']); + expect(shouldAutoLoadPid(70, log)).toBe(false); + expect(shouldAutoLoadPid(71, log)).toBe(false); + expect(shouldAutoLoadPid(72, log)).toBe(false); + }); + + it('allows everything if SNOWLUMA_HOOK_AUTOLOAD_ALL=1 (escape hatch)', () => { + writeCmdline(59, ['/opt/QQ/qq', '--type=zygote']); + process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = '1'; + expect(shouldAutoLoadPid(59, log)).toBe(true); + }); + + it('allows on non-linux platforms (filter is a Linux-only workaround)', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + // No cmdline file needed — we return true before touching procfs. + expect(shouldAutoLoadPid(59, log)).toBe(true); + }); + + it('allows when /proc is unreadable (dead PID, permission issue)', () => { + expect(shouldAutoLoadPid(99999, log)).toBe(true); + }); +}); diff --git a/packages/runtime/native/snowluma-linux-x64.so b/packages/runtime/native/snowluma-linux-x64.so index 3cabf2e5..a778faa7 100644 Binary files a/packages/runtime/native/snowluma-linux-x64.so and b/packages/runtime/native/snowluma-linux-x64.so differ