diff --git a/src/main/daemon/daemon-cgroup-scope.ts b/src/main/daemon/daemon-cgroup-scope.ts index 2f93a711eb86..f4893f5ad4fa 100644 --- a/src/main/daemon/daemon-cgroup-scope.ts +++ b/src/main/daemon/daemon-cgroup-scope.ts @@ -35,7 +35,7 @@ const LEGACY_SCOPE_PREFIX = 'app-orca-' * provisions, from `getuid()` rather than from the environment. The exported functions' * `canonicalRuntimeDir` parameters default to this so production uses the real path; tests * inject a fake one. */ -const CANONICAL_USER_RUNTIME_DIR = +export const CANONICAL_USER_RUNTIME_DIR = typeof process.getuid === 'function' ? `/run/user/${process.getuid()}` : null /** systemd unit names are restricted to `[A-Za-z0-9:_.\-@]`; sanitize defensively even though @@ -65,7 +65,10 @@ function hasReachableBus(runtimeDir: string): boolean { * "unsupported" on every host hardened that way. It stays as the fallback for hosts that have no * `/run/user/` but do have a working bus wherever they point. */ -function resolveUserRuntimeDir(env: NodeJS.ProcessEnv, canonicalDir: string | null): string | null { +export function resolveUserRuntimeDir( + env: NodeJS.ProcessEnv, + canonicalDir: string | null +): string | null { if (canonicalDir && hasReachableBus(canonicalDir)) { return canonicalDir } diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a3b5d0d42520..9fdd7ff97a12 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -1,13 +1,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnDaemonChildProcess } from './daemon-launched-child-spawn' -const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() })) +const { spawn, fork, repairSessionBusEnv } = vi.hoisted(() => { + /** Apply a fixed address so launch tests isolate env forwarding from socket lookup. */ + const repairSessionBusEnv = vi.fn((env: Record) => { + if (env.DBUS_SESSION_BUS_ADDRESS === 'disabled:') { + env.DBUS_SESSION_BUS_ADDRESS = 'unix:path=/run/user/1000/bus' + } + }) + return { spawn: vi.fn(), fork: vi.fn(), repairSessionBusEnv } +}) vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: spawn })) vi.mock('../../shared/child-process/fork-process', () => ({ forkProcess: fork })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getVersion: () => '1.0.0' }) })) vi.mock('./daemon-launch-paths', () => ({ daemonLogArgs: () => [] })) +vi.mock('../pty/dbus-session-bus-env', () => ({ + repairDisabledSessionBusEnv: repairSessionBusEnv +})) const options = { entryPath: '/app/daemon-entry.js', @@ -20,7 +31,10 @@ const options = { macosLoginSessionWatch: false } -afterEach(() => vi.clearAllMocks()) +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() +}) describe('daemon launch scope ownership', () => { it('only arms lifetime cleanup through the private scope launcher', () => { @@ -49,3 +63,23 @@ describe('daemon launch scope ownership', () => { ) }) }) + +describe('daemon launch session bus', () => { + it.each([true, false])( + 'hands the repaired bus address to the daemon (scoped launch: %s)', + (useDurableScope) => { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'disabled:') + + spawnDaemonChildProcess(options, useDurableScope) + + const launch = useDurableScope ? spawn : fork + expect(launch).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' + }) + }) + ) + } + ) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 887241e2daab..33af9e989dbb 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,6 +1,7 @@ import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { getAppEnvironment } from '../../shared/app-environment' +import { repairDisabledSessionBusEnv } from '../pty/dbus-session-bus-env' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -58,12 +59,15 @@ export function spawnDaemonChildProcess( const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. - const daemonEnv = { + const daemonEnv: Record = { ...process.env, ELECTRON_RUN_AS_NODE: '1', // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } + // Why: headless serve inherits Chromium's disabled session-bus marker, which would + // otherwise flow into the daemon and every shell it spawns. + repairDisabledSessionBusEnv(daemonEnv) // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 6fdf7b70da6a..d82180285eac 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -5,6 +5,7 @@ import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../../pty/pi-process-owner-env' import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' +import { repairDisabledSessionBusEnv } from '../../pty/dbus-session-bus-env' import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir' import { removeInheritedNoColor } from '../../pty/terminal-color-env' import { resolvePathEnvKey } from '../../pty/windows-environment-path' @@ -187,6 +188,7 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const repairedAddress = 'unix:path=/run/user/1000/bus' + /** Apply a fixed address so wiring tests isolate call sites from socket lookup. */ + const repairSessionBusEnv = (env: Record) => { + if (env.DBUS_SESSION_BUS_ADDRESS === 'disabled:') { + env.DBUS_SESSION_BUS_ADDRESS = repairedAddress + } + } + return { REPAIRED: repairedAddress, repairSessionBusEnv } +}) + +// Why: the helper itself is covered against real sockets in dbus-session-bus-env.test.ts. Here a +// stand-in proves each spawn path calls it on the final env, so removing a call fails a test. +vi.mock('./dbus-session-bus-env', () => ({ + repairDisabledSessionBusEnv: repairSessionBusEnv +})) + +vi.mock('../providers/local-pty-utils', () => ({ + ensureNodePtySpawnHelperExecutable: vi.fn(), + validateWorkingDirectory: vi.fn() +})) + +afterEach(() => vi.unstubAllEnvs()) + +describe('session-bus repair wiring', () => { + it('repairs the marker inherited by daemon PTY shells', () => { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'disabled:') + + const env = createDaemonPtyEnvironment({ sessionId: 'session', cols: 80, rows: 24 }) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(REPAIRED) + }) + + it('repairs the marker passed explicitly to a daemon PTY', () => { + const env = createDaemonPtyEnvironment({ + sessionId: 'session', + cols: 80, + rows: 24, + env: { DBUS_SESSION_BUS_ADDRESS: 'disabled:' } + }) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(REPAIRED) + }) + + it.skipIf(process.platform === 'win32')( + 'repairs the marker inherited by local PTY shells', + () => { + vi.stubEnv('DBUS_SESSION_BUS_ADDRESS', 'disabled:') + const spawn = { cwd: '/tmp', cols: 80, rows: 24 } + const getOptions = () => ({}) + const plan = createLocalPtyLaunchPlan(spawn, () => ({ getDefaultShell: () => '/bin/sh' })) + if (plan instanceof DeferredLocalPtyLaunchPlan) { + throw new Error('a POSIX launch plan is never deferred') + } + + const env = buildLocalPtySpawnEnvironment({ id: 'pty', spawn, getOptions, plan }) + + expect(env).toMatchObject({ DBUS_SESSION_BUS_ADDRESS: REPAIRED }) + } + ) +}) diff --git a/src/main/pty/dbus-session-bus-env.test.ts b/src/main/pty/dbus-session-bus-env.test.ts new file mode 100644 index 000000000000..2478df7cda6e --- /dev/null +++ b/src/main/pty/dbus-session-bus-env.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { repairDisabledSessionBusEnv } from './dbus-session-bus-env' + +const servers: Server[] = [] +const dirs: string[] = [] + +/** Create a private runtime directory with a listening Unix-domain socket at `bus`. */ +function runtimeDirWithBus(): string { + const dir = mkdtempSync(join(tmpdir(), 'xdg-runtime-with-bus-')) + const server = createServer() + server.listen(join(dir, 'bus')) + servers.push(server) + dirs.push(dir) + return dir +} + +/** Create a private runtime directory without a bus socket. */ +function runtimeDirWithoutBus(): string { + const dir = mkdtempSync(join(tmpdir(), 'xdg-runtime-no-bus-')) + dirs.push(dir) + return dir +} + +afterEach(() => { + for (const server of servers.splice(0)) { + server.close() + } + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('repairDisabledSessionBusEnv', () => { + it.skipIf(process.platform === 'win32')( + 'replaces the disabled marker with the per-UID user bus', + () => { + const perUidDir = runtimeDirWithBus() + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' } + + repairDisabledSessionBusEnv(env, 'linux', perUidDir) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(`unix:path=${perUidDir}/bus`) + } + ) + + it.skipIf(process.platform === 'win32')( + 'prefers the per-UID bus over a hardened XDG_RUNTIME_DIR without a bus', + () => { + const perUidDir = runtimeDirWithBus() + const env: Record = { + DBUS_SESSION_BUS_ADDRESS: 'disabled:', + XDG_RUNTIME_DIR: runtimeDirWithoutBus() + } + + repairDisabledSessionBusEnv(env, 'linux', perUidDir) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(`unix:path=${perUidDir}/bus`) + } + ) + + it.skipIf(process.platform === 'win32')( + 'falls back to XDG_RUNTIME_DIR when there is no per-UID bus', + () => { + const xdgDir = runtimeDirWithBus() + const env: Record = { + DBUS_SESSION_BUS_ADDRESS: 'disabled:', + XDG_RUNTIME_DIR: xdgDir + } + + repairDisabledSessionBusEnv(env, 'linux', runtimeDirWithoutBus()) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(`unix:path=${xdgDir}/bus`) + } + ) + + it('leaves the marker when no reachable bus exists', () => { + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' } + + repairDisabledSessionBusEnv(env, 'linux', runtimeDirWithoutBus()) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('disabled:') + }) + + it('does not treat a plain file named bus as a bus', () => { + const dir = runtimeDirWithoutBus() + writeFileSync(join(dir, 'bus'), '') + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' } + + repairDisabledSessionBusEnv(env, 'linux', dir) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('disabled:') + }) + + it('leaves a real bus address and an absent address untouched', () => { + const perUidDir = runtimeDirWithoutBus() + const withAddress: Record = { + DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' + } + const withoutAddress: Record = { HOME: '/home/user' } + + repairDisabledSessionBusEnv(withAddress, 'linux', perUidDir) + repairDisabledSessionBusEnv(withoutAddress, 'linux', perUidDir) + + expect(withAddress).toEqual({ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }) + expect(withoutAddress).toEqual({ HOME: '/home/user' }) + }) + + it('does nothing off Linux', () => { + const env: Record = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' } + + repairDisabledSessionBusEnv(env, 'darwin', runtimeDirWithoutBus()) + + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('disabled:') + }) +}) diff --git a/src/main/pty/dbus-session-bus-env.ts b/src/main/pty/dbus-session-bus-env.ts new file mode 100644 index 000000000000..c44fe7ba835c --- /dev/null +++ b/src/main/pty/dbus-session-bus-env.ts @@ -0,0 +1,30 @@ +import { CANONICAL_USER_RUNTIME_DIR, resolveUserRuntimeDir } from '../daemon/daemon-cgroup-scope' + +const DISABLED_SESSION_BUS_ADDRESS = 'disabled:' + +/** + * Chromium sets `DBUS_SESSION_BUS_ADDRESS=disabled:` when it starts without a session bus, and + * service hardening can set the same marker. Headless serve then passes it into the daemon and + * every shell or agent it spawns, so `systemctl --user` and `systemd-run --user` fail with + * "Connection refused" although the user bus is healthy. + * + * Only the exact marker is changed. It becomes the address of the bus that + * `resolveUserRuntimeDir` finds (the per-UID runtime dir first, then `XDG_RUNTIME_DIR`), the + * same resolution the durable daemon scope uses. When no bus is reachable, the env stays as it + * is. An explicit address, not a deleted variable, because shells can carry an + * `XDG_RUNTIME_DIR` that points at a private dir with no bus in it. + */ +export function repairDisabledSessionBusEnv( + env: Record, + platform: NodeJS.Platform = process.platform, + canonicalRuntimeDir: string | null = CANONICAL_USER_RUNTIME_DIR +): void { + if (platform !== 'linux' || env.DBUS_SESSION_BUS_ADDRESS !== DISABLED_SESSION_BUS_ADDRESS) { + return + } + const runtimeDir = resolveUserRuntimeDir(env, canonicalRuntimeDir) + if (!runtimeDir) { + return + } + env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${runtimeDir}/bus` +}