From 041810df182941fd9dfa68d201580e7fb6ce7bf1 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Thu, 18 Jun 2026 19:19:41 +0800 Subject: [PATCH 1/2] fix(core): probe and pass --no-ask-password to systemd-inhibit On Linux systems with a desktop environment, running qwen-code over SSH triggers a polkit authentication prompt from systemd-inhibit that corrupts the TUI input stream. Probe systemd-inhibit --help to detect whether --no-ask-password is supported, cache the result, and prepend the flag when spawning the inhibitor. Fixes #5281 --- .../core/src/services/sleepInhibitor.test.ts | 136 +++++++++++++++--- packages/core/src/services/sleepInhibitor.ts | 81 +++++++++-- 2 files changed, 180 insertions(+), 37 deletions(-) diff --git a/packages/core/src/services/sleepInhibitor.test.ts b/packages/core/src/services/sleepInhibitor.test.ts index f992ffa707f..02b2ab3faf3 100644 --- a/packages/core/src/services/sleepInhibitor.test.ts +++ b/packages/core/src/services/sleepInhibitor.test.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events'; import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { describe, expect, it, vi } from 'vitest'; import { acquireSleepInhibitor, SleepInhibitor } from './sleepInhibitor.js'; +import { PassThrough } from 'node:stream'; function createChild(pid: number | undefined = 4242): ChildProcess { const child = new EventEmitter() as ChildProcess; @@ -26,13 +27,49 @@ function createChild(pid: number | undefined = 4242): ChildProcess { return child; } +function createHelpChild(output: string): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + Object.defineProperty(child, 'stdout', { value: stdout }); + Object.defineProperty(child, 'stderr', { value: stderr }); + Object.defineProperty(child, 'pid', { value: 9999 }); + Object.defineProperty(child, 'killed', { value: false }); + child.kill = vi.fn(() => true); + queueMicrotask(() => { + stdout.write(output); + stdout.end(); + child.emit('close', 0, null); + }); + return child; +} + +function createErrorChild(): ChildProcess { + const child = new EventEmitter() as ChildProcess; + Object.defineProperty(child, 'pid', { value: undefined }); + Object.defineProperty(child, 'killed', { value: false }); + child.kill = vi.fn(() => true); + queueMicrotask(() => child.emit('error', new Error('ENOENT'))); + return child; +} + function createHarness( platform: NodeJS.Platform = 'linux', env: NodeJS.ProcessEnv = {}, + noAskPasswordSupported: boolean | null = true, ) { const children: ChildProcess[] = []; + const helpOutput = noAskPasswordSupported + ? 'systemd-inhibit [OPTIONS...] COMMAND ...\n\n --no-ask-password Do not attempt interactive authorization\n' + : 'systemd-inhibit [OPTIONS...] COMMAND ...\n\n --what=WHAT Operations to inhibit\n'; const spawn = vi.fn( - (_command: string, _args: string[], _options?: SpawnOptions) => { + (command: string, args: string[], _options?: SpawnOptions) => { + if (command === 'systemd-inhibit' && args[0] === '--help') { + if (noAskPasswordSupported === null) { + return createErrorChild(); + } + return createHelpChild(helpOutput); + } const child = createChild(); children.push(child); return child; @@ -47,16 +84,23 @@ function createHarness( } describe('SleepInhibitor', () => { - it('starts systemd-inhibit on linux and stops it after the final release', () => { + it('starts systemd-inhibit on linux and stops it after the final release', async () => { const { children, inhibitor, spawn } = createHarness('linux'); const first = inhibitor.acquire('working'); const second = inhibitor.acquire('working again'); + // Initially only the --help probe is spawned expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0]![0]).toBe('systemd-inhibit'); + expect(spawn.mock.calls[0]![1]).toEqual(['--help']); + + // After probe completes, the real inhibitor spawns + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); expect(spawn).toHaveBeenCalledWith( 'systemd-inhibit', [ + '--no-ask-password', '--what=sleep', '--who=Qwen Code', '--why=working', @@ -103,7 +147,7 @@ describe('SleepInhibitor', () => { expect(inhibitor.getActiveCount()).toBe(0); }); - it('starts systemd-inhibit for SSH sessions with a display server', () => { + it('starts systemd-inhibit for SSH sessions with a display server', async () => { const { inhibitor, spawn } = createHarness('linux', { SSH_TTY: '/dev/pts/3', DISPLAY: ':10', @@ -111,6 +155,7 @@ describe('SleepInhibitor', () => { const handle = inhibitor.acquire('forwarded display work'); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); expect(spawn).toHaveBeenCalledWith( 'systemd-inhibit', expect.arrayContaining(['--what=sleep']), @@ -119,11 +164,12 @@ describe('SleepInhibitor', () => { handle.release(); }); - it('starts systemd-inhibit for local headless Linux sessions', () => { + it('starts systemd-inhibit for local headless Linux sessions', async () => { const { inhibitor, spawn } = createHarness('linux'); const handle = inhibitor.acquire('local headless work'); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); expect(spawn).toHaveBeenCalledWith( 'systemd-inhibit', expect.arrayContaining(['--what=sleep']), @@ -132,14 +178,15 @@ describe('SleepInhibitor', () => { handle.release(); }); - it('forwards a curated environment instead of an empty env', () => { + it('forwards a curated environment instead of an empty env', async () => { const { inhibitor, spawn } = createHarness('linux', { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus', SOME_UNRELATED_SECRET: 'secret', }); const handle = inhibitor.acquire(); - const env = spawn.mock.calls[0]![2]!.env as NodeJS.ProcessEnv; + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); + const env = spawn.mock.calls[1]![2]!.env as NodeJS.ProcessEnv; // D-Bus address required by systemd-inhibit must be forwarded. expect(env['DBUS_SESSION_BUS_ADDRESS']).toBe( 'unix:path=/run/user/1000/bus', @@ -177,10 +224,11 @@ describe('SleepInhibitor', () => { handle.release(); }); - it('ignores duplicate releases', () => { + it('ignores duplicate releases', async () => { const { children, inhibitor } = createHarness('linux'); const handle = inhibitor.acquire(); + await vi.waitFor(() => expect(inhibitor.isRunning()).toBe(true)); handle.release(); handle.release(); @@ -209,10 +257,11 @@ describe('SleepInhibitor', () => { expect(inhibitor.getActiveCount()).toBe(0); }); - it('handles async error events from the spawned child', () => { + it('handles async error events from the spawned child', async () => { const { children, inhibitor, logger } = createHarness('linux'); const handle = inhibitor.acquire(); + await vi.waitFor(() => expect(children).toHaveLength(1)); children[0]!.emit('error', new Error('EPERM')); expect(inhibitor.isRunning()).toBe(false); @@ -222,10 +271,11 @@ describe('SleepInhibitor', () => { handle.release(); }); - it('restarts after an unexpected exit when acquired again', () => { + it('restarts after an unexpected exit when acquired again', async () => { const { children, inhibitor, logger, spawn } = createHarness('linux'); const first = inhibitor.acquire('initial work'); + await vi.waitFor(() => expect(children).toHaveLength(1)); children[0]!.emit('exit', 1, null); expect(inhibitor.isRunning()).toBe(false); @@ -234,7 +284,7 @@ describe('SleepInhibitor', () => { ); const second = inhibitor.acquire('more work'); - expect(spawn).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(3)); expect(inhibitor.isRunning()).toBe(true); first.release(); @@ -255,13 +305,13 @@ describe('SleepInhibitor', () => { expect(() => missingGetter.release()).not.toThrow(); }); - it('dispose kills the active child, resets state, and is idempotent', () => { + it('dispose kills the active child, resets state, and is idempotent', async () => { const { children, inhibitor } = createHarness('linux'); inhibitor.acquire('work'); inhibitor.acquire('more work'); + await vi.waitFor(() => expect(inhibitor.isRunning()).toBe(true)); expect(inhibitor.getActiveCount()).toBe(2); - expect(inhibitor.isRunning()).toBe(true); inhibitor.dispose(); expect(children[0]!.kill).toHaveBeenCalledTimes(1); @@ -273,9 +323,12 @@ describe('SleepInhibitor', () => { expect(children[0]!.kill).toHaveBeenCalledTimes(1); }); - it('does not propagate when child.kill() throws during release', () => { + it('does not propagate when child.kill() throws during release', async () => { const children: ChildProcess[] = []; - const spawn = vi.fn(() => { + const spawn = vi.fn((command: string, args: string[]) => { + if (command === 'systemd-inhibit' && args[0] === '--help') { + return createHelpChild('--no-ask-password'); + } const child = new EventEmitter() as ChildProcess; Object.defineProperty(child, 'killed', { get: () => false }); Object.defineProperty(child, 'pid', { value: 4242 }); @@ -294,6 +347,7 @@ describe('SleepInhibitor', () => { }); const handle = inhibitor.acquire(); + await vi.waitFor(() => expect(children).toHaveLength(1)); expect(() => handle.release()).not.toThrow(); expect(logger.warn).toHaveBeenCalledWith( 'Failed to stop sleep inhibitor: ESRCH', @@ -301,7 +355,7 @@ describe('SleepInhibitor', () => { expect(inhibitor.getActiveCount()).toBe(0); }); - it('does not kill a child whose spawn failed (no pid)', () => { + it('does not kill a child whose spawn failed (no pid)', async () => { // Mimics the container sandbox: `systemd-inhibit` is absent, so the spawn // rejects with ENOENT on the next tick and the child never gets a pid. If // `stop()` (here via the synchronous release before the error event fires) @@ -309,10 +363,13 @@ describe('SleepInhibitor', () => { // caller's own process group and deliver SIGTERM to this process, aborting // the run. Releasing must therefore be a no-op for a pidless child. const children: ChildProcess[] = []; - const spawn = vi.fn(() => { - // Pidless child: spawn returned but the process never started (ENOENT). + const spawn = vi.fn((command: string, args: string[]) => { + if (command === 'systemd-inhibit' && args[0] === '--help') { + return createErrorChild(); + } const child = new EventEmitter() as ChildProcess; Object.defineProperty(child, 'killed', { get: () => false }); + // Pidless child: spawn returned but the process never started (ENOENT). Object.defineProperty(child, 'pid', { value: undefined }); child.kill = vi.fn(() => true); children.push(child); @@ -327,7 +384,7 @@ describe('SleepInhibitor', () => { }); const handle = inhibitor.acquire('executing tool'); - expect(spawn).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); handle.release(); @@ -336,14 +393,15 @@ describe('SleepInhibitor', () => { expect(inhibitor.isRunning()).toBe(false); }); - it('ignores a late error event from an already-replaced child', () => { + it('ignores a late error event from an already-replaced child', async () => { const { children, inhibitor, logger, spawn } = createHarness('linux'); const first = inhibitor.acquire(); + await vi.waitFor(() => expect(children).toHaveLength(1)); // First child exits, so this.child is cleared and a re-acquire respawns. children[0]!.emit('exit', 0, null); const second = inhibitor.acquire(); - expect(spawn).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(3)); expect(inhibitor.isRunning()).toBe(true); logger.debug.mockClear(); @@ -379,11 +437,12 @@ describe('SleepInhibitor', () => { second.release(); }); - it('sanitizes the systemd-inhibit reason (strips control chars, caps length)', () => { + it('sanitizes the systemd-inhibit reason (strips control chars, caps length)', async () => { const { inhibitor, spawn } = createHarness('linux'); const handle = inhibitor.acquire(`run\x00 tool\n${'x'.repeat(200)}`); - const args = spawn.mock.calls[0]![1] as string[]; + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); + const args = spawn.mock.calls[1]![1] as string[]; const why = args.find((arg) => arg.startsWith('--why='))!; // eslint-disable-next-line no-control-regex @@ -392,4 +451,37 @@ describe('SleepInhibitor', () => { handle.release(); }); + + it.each([ + { + support: true, + expected: true, + label: 'includes --no-ask-password when supported', + }, + { + support: false, + expected: false, + label: 'omits --no-ask-password when not supported', + }, + { + support: null, + expected: false, + label: 'omits --no-ask-password when unavailable', + }, + ])('$label', async ({ support, expected }) => { + const { inhibitor, spawn } = createHarness('linux', {}, support); + + const handle = inhibitor.acquire('working'); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)); + const args = spawn.mock.calls[1]![1] as string[]; + + if (expected) { + expect(args[0]).toBe('--no-ask-password'); + } else { + expect(args).not.toContain('--no-ask-password'); + } + expect(args).toContain('--what=sleep'); + + handle.release(); + }); }); diff --git a/packages/core/src/services/sleepInhibitor.ts b/packages/core/src/services/sleepInhibitor.ts index 7a60dd2b733..4cb1f676a1c 100644 --- a/packages/core/src/services/sleepInhibitor.ts +++ b/packages/core/src/services/sleepInhibitor.ts @@ -68,6 +68,8 @@ export class SleepInhibitor { private readonly env: NodeJS.ProcessEnv; private readonly spawn: NonNullable; private readonly logger: NonNullable; + private noAskPasswordSupported: boolean | undefined; + private probing = false; constructor(config: SleepInhibitorConfig = {}) { this.platform = config.platform ?? defaultPlatform(); @@ -84,7 +86,7 @@ export class SleepInhibitor { if (this.activeCount === 1) { this.spawnFailedForCurrentRun = false; this.start(reason); - } else if (!this.child && !this.spawnFailedForCurrentRun) { + } else if (!this.child && !this.spawnFailedForCurrentRun && !this.probing) { this.start(reason); } @@ -105,7 +107,7 @@ export class SleepInhibitor { } isRunning(): boolean { - return this.child !== undefined; + return this.child !== undefined && this.probing; } private release(): void { @@ -121,10 +123,56 @@ export class SleepInhibitor { } private start(reason: string): void { - if (this.child || this.spawnFailedForCurrentRun) { + if (this.child || this.spawnFailedForCurrentRun || this.probing) { return; } + if (this.platform === 'linux') { + if (this.noAskPasswordSupported === undefined) { + this.probing = true; + this.probeNoAskPassword(() => { + this.probing = false; + this.doStart(reason); + }); + return; + } + } + this.doStart(reason); + } + + /** + * Spawn `systemd-inhibit --help` and inspect the output to determine whether + * `--no-ask-password` is supported. The result is cached so the probe only + * runs once per process lifetime. + */ + private probeNoAskPassword(callback: () => void): void { + try { + const probe = this.spawn('systemd-inhibit', ['--help'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let settled = false; + const settle = (supported: boolean): void => { + if (settled) return; + settled = true; + this.noAskPasswordSupported = supported; + callback(); + }; + probe.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + probe.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + probe.on('error', () => settle(false)); + probe.on('close', () => settle(output.includes('--no-ask-password'))); + } catch { + this.noAskPasswordSupported = false; + callback(); + } + } + + private doStart(reason: string): void { const command = this.getCommand(reason); if (!command) { this.logger.debug(this.getUnavailableMessage()); @@ -248,21 +296,24 @@ export class SleepInhibitor { // way to block that, so this does not fully match the Linux // systemd-inhibit semantics on battery. return { command: 'caffeinate', args: ['-is'] }; - case 'linux': + case 'linux': { if (isHeadlessSshSession(this.env)) { return undefined; } - return { - command: 'systemd-inhibit', - args: [ - '--what=sleep', - '--who=Qwen Code', - `--why=${sanitizeInhibitorReason(reason)}`, - '--mode=block', - 'sleep', - 'infinity', - ], - }; + const args: string[] = []; + if (this.noAskPasswordSupported) { + args.push('--no-ask-password'); + } + args.push( + '--what=sleep', + '--who=Qwen Code', + `--why=${sanitizeInhibitorReason(reason)}`, + '--mode=block', + 'sleep', + 'infinity', + ); + return { command: 'systemd-inhibit', args }; + } case 'win32': return { command: 'powershell.exe', From 4113a3b129b1fd440643525907df5feb7f66fd61 Mon Sep 17 00:00:00 2001 From: OrbitZore Date: Fri, 19 Jun 2026 10:55:16 +0800 Subject: [PATCH 2/2] fix(core): skip probe for headless SSH and fix isRunning() regression - Revert isRunning() to return this.child !== undefined (probing flag was incorrectly ANDed, returning false after probe completed) - Skip --no-ask-password probe when systemd-inhibit won't be used (headless SSH sessions), avoiding unnecessary process spawn - Guard probe callback with activeCount > 0 to prevent orphaned child if dispose() is called during probe Fixes 6 failing tests in sleepInhibitor.test.ts --- packages/core/src/services/sleepInhibitor.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/sleepInhibitor.ts b/packages/core/src/services/sleepInhibitor.ts index 4cb1f676a1c..3972cdeaf97 100644 --- a/packages/core/src/services/sleepInhibitor.ts +++ b/packages/core/src/services/sleepInhibitor.ts @@ -107,7 +107,7 @@ export class SleepInhibitor { } isRunning(): boolean { - return this.child !== undefined && this.probing; + return this.child !== undefined; } private release(): void { @@ -127,12 +127,14 @@ export class SleepInhibitor { return; } - if (this.platform === 'linux') { + if (this.platform === 'linux' && !isHeadlessSshSession(this.env)) { if (this.noAskPasswordSupported === undefined) { this.probing = true; this.probeNoAskPassword(() => { this.probing = false; - this.doStart(reason); + if (this.activeCount > 0) { + this.doStart(reason); + } }); return; }