Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/main/daemon/daemon-cgroup-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<uid>` 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
}
Expand Down
38 changes: 36 additions & 2 deletions src/main/daemon/daemon-launched-child-spawn.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) => {
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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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'
})
})
)
}
)
})
6 changes: 5 additions & 1 deletion src/main/daemon/daemon-launched-child-spawn.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<string, string | undefined> = {
...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<ForkSpec, 'cwd' | 'detached' | 'stdio'> = {
Expand Down
2 changes: 2 additions & 0 deletions src/main/daemon/pty-subprocess/spawn-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -187,6 +188,7 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record<s
removeInheritedDevAgentHookEndpoint(env, opts.env)
delete env.ELECTRON_RUN_AS_NODE
removeAppImageRuntimeEnv(env)
repairDisabledSessionBusEnv(env)
removeInheritedNoColor(env)
env.LANG ??= 'en_US.UTF-8'
return env
Expand Down
2 changes: 2 additions & 0 deletions src/main/providers/local-pty-spawn-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '../../shared/terminal-image-protocol'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { repairDisabledSessionBusEnv } from '../pty/dbus-session-bus-env'
import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { isWindowsGitBashShellPath } from '../git-bash'
Expand Down Expand Up @@ -35,6 +36,7 @@ export function buildLocalPtySpawnEnvironment(args: {
removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env)
stripPiProcessOwnerEnv(spawnEnv)
removeAppImageRuntimeEnv(spawnEnv)
repairDisabledSessionBusEnv(spawnEnv)
removeInheritedNoColor(spawnEnv)
for (const key of spawn.envToDelete ?? []) {
delete spawnEnv[key]
Expand Down
69 changes: 69 additions & 0 deletions src/main/pty/dbus-session-bus-env-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment'
import {
createLocalPtyLaunchPlan,
DeferredLocalPtyLaunchPlan
} from '../providers/local-pty-launch-plan'
import { buildLocalPtySpawnEnvironment } from '../providers/local-pty-spawn-environment'

const { REPAIRED, repairSessionBusEnv } = vi.hoisted(() => {
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<string, string | undefined>) => {
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 })
}
)
})
119 changes: 119 additions & 0 deletions src/main/pty/dbus-session-bus-env.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<string, string | undefined> = { 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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = {
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus'
}
const withoutAddress: Record<string, string | undefined> = { 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<string, string | undefined> = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' }

repairDisabledSessionBusEnv(env, 'darwin', runtimeDirWithoutBus())

expect(env.DBUS_SESSION_BUS_ADDRESS).toBe('disabled:')
})
})
30 changes: 30 additions & 0 deletions src/main/pty/dbus-session-bus-env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
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`
}