diff --git a/setup/service.ts b/setup/service.ts index a866a9248..ba309c67e 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -22,6 +22,7 @@ import { isWSL, } from './platform.js'; import { emitStatus } from './status.js'; +import { computeUserSystemdEnv } from './systemd-user-env.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -291,12 +292,34 @@ function setupSystemd( systemctlPrefix = 'systemctl'; log.info('Running as root — installing system-level systemd unit'); } else { + // pam_systemd normally exports XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS + // on login, but invocations via `su -`, `pct enter`, and other non-pam + // entry points skip that step. The user systemd manager is still running + // (linger keeps it alive across sessions), but the daemon-reload probe + // below can't reach it without the env vars. Re-derive them from on-disk + // state before probing so we don't false-negative into the nohup path + // when a real systemd user session is available. See #2482. + const envResult = computeUserSystemdEnv({ + uid: process.getuid?.(), + user: process.env.USER || process.env.LOGNAME, + env: { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR }, + exists: fs.existsSync, + }); + if (envResult.reason === 'populated') { + process.env.XDG_RUNTIME_DIR = envResult.XDG_RUNTIME_DIR; + process.env.DBUS_SESSION_BUS_ADDRESS = envResult.DBUS_SESSION_BUS_ADDRESS; + log.info('Populated systemd user env from linger state', { + XDG_RUNTIME_DIR: envResult.XDG_RUNTIME_DIR, + }); + } + // Check if user-level systemd session is available try { execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); } catch { log.warn( 'systemd user session not available — falling back to nohup wrapper', + { envProbeReason: envResult.reason }, ); setupNohupFallback(projectRoot, nodePath, homeDir); return; diff --git a/setup/systemd-user-env.test.ts b/setup/systemd-user-env.test.ts new file mode 100644 index 000000000..1e7c40014 --- /dev/null +++ b/setup/systemd-user-env.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; + +import { computeUserSystemdEnv } from './systemd-user-env.js'; + +function existsFrom(paths: Set) { + return (p: string) => paths.has(p); +} + +describe('computeUserSystemdEnv', () => { + it('populates env when linger is on and the runtime dir exists (#2482 repro)', () => { + const result = computeUserSystemdEnv({ + uid: 1000, + user: 'nanoclaw', + env: {}, + exists: existsFrom( + new Set(['/var/lib/systemd/linger/nanoclaw', '/run/user/1000']), + ), + }); + + expect(result).toEqual({ + reason: 'populated', + XDG_RUNTIME_DIR: '/run/user/1000', + DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus', + }); + }); + + it('no-ops when XDG_RUNTIME_DIR is already set (SSH login path)', () => { + const result = computeUserSystemdEnv({ + uid: 1000, + user: 'nanoclaw', + env: { XDG_RUNTIME_DIR: '/run/user/1000' }, + exists: existsFrom( + new Set(['/var/lib/systemd/linger/nanoclaw', '/run/user/1000']), + ), + }); + + expect(result.reason).toBe('already_set'); + expect(result.XDG_RUNTIME_DIR).toBeUndefined(); + expect(result.DBUS_SESSION_BUS_ADDRESS).toBeUndefined(); + }); + + it('returns no_linger when the linger marker is absent', () => { + const result = computeUserSystemdEnv({ + uid: 1000, + user: 'nanoclaw', + env: {}, + exists: existsFrom(new Set(['/run/user/1000'])), + }); + + expect(result.reason).toBe('no_linger'); + expect(result.XDG_RUNTIME_DIR).toBeUndefined(); + }); + + it('returns no_runtime_dir when linger is on but /run/user/ is missing', () => { + // The defensive guard from #2482: without this we would point env vars + // at a non-existent socket and the daemon-reload probe would fail with a + // less recoverable error than the bare "No medium found". + const result = computeUserSystemdEnv({ + uid: 1000, + user: 'nanoclaw', + env: {}, + exists: existsFrom(new Set(['/var/lib/systemd/linger/nanoclaw'])), + }); + + expect(result.reason).toBe('no_runtime_dir'); + expect(result.XDG_RUNTIME_DIR).toBeUndefined(); + }); + + it('returns no_user when USER and LOGNAME are both missing', () => { + const result = computeUserSystemdEnv({ + uid: 1000, + user: undefined, + env: {}, + exists: existsFrom(new Set()), + }); + + expect(result.reason).toBe('no_user'); + }); + + it('returns no_uid when process.getuid is unavailable', () => { + const result = computeUserSystemdEnv({ + uid: undefined, + user: 'nanoclaw', + env: {}, + exists: existsFrom(new Set()), + }); + + expect(result.reason).toBe('no_uid'); + }); +}); diff --git a/setup/systemd-user-env.ts b/setup/systemd-user-env.ts new file mode 100644 index 000000000..9020e045a --- /dev/null +++ b/setup/systemd-user-env.ts @@ -0,0 +1,38 @@ +export interface SystemdUserEnvDeps { + uid: number | undefined; + user: string | undefined; + env: { XDG_RUNTIME_DIR?: string }; + exists: (path: string) => boolean; +} + +export type SystemdUserEnvReason = + | 'already_set' + | 'no_user' + | 'no_uid' + | 'no_linger' + | 'no_runtime_dir' + | 'populated'; + +export interface SystemdUserEnvResult { + reason: SystemdUserEnvReason; + XDG_RUNTIME_DIR?: string; + DBUS_SESSION_BUS_ADDRESS?: string; +} + +export function computeUserSystemdEnv( + deps: SystemdUserEnvDeps, +): SystemdUserEnvResult { + if (deps.env.XDG_RUNTIME_DIR) return { reason: 'already_set' }; + if (!deps.user) return { reason: 'no_user' }; + if (typeof deps.uid !== 'number') return { reason: 'no_uid' }; + if (!deps.exists(`/var/lib/systemd/linger/${deps.user}`)) { + return { reason: 'no_linger' }; + } + const runtimeDir = `/run/user/${deps.uid}`; + if (!deps.exists(runtimeDir)) return { reason: 'no_runtime_dir' }; + return { + reason: 'populated', + XDG_RUNTIME_DIR: runtimeDir, + DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDir}/bus`, + }; +}