From 1512e3f19e9ba6f07be4699cdd3515cb3f34eac8 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 15 May 2026 17:16:12 +0200 Subject: [PATCH] fix(setup): re-probe systemd user session with derived env on su- entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setup/service.ts` decides between a real systemd user unit and the nohup fallback by running `systemctl --user daemon-reload` and watching for the call to succeed. In contexts that bypass pam_systemd — `su -`, `pct enter`, headless containers — `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS` are not exported, the probe fails with `Failed to connect to bus: No medium found`, and we install nohup despite the user manager being healthy on disk. Before the probe, check whether linger is enabled for the current user and `/run/user/` exists; if so, re-derive the env vars from disk and let them propagate to subsequent `systemctl --user` calls via `process.env` (execSync inherits by default). If the probe still fails after that, the existing nohup fallback runs unchanged — and the warning log now records *which* precondition failed so the cause is visible without grepping setup.log. The pure decision function lives in `setup/systemd-user-env.ts` so it can be tested without execSync. New regression test in `setup/systemd-user-env.test.ts` covers the #2482 repro plus the already_set / no_linger / no_runtime_dir / no_user / no_uid branches. Closes #1981 Closes #2482 --- setup/service.ts | 23 +++++++++ setup/systemd-user-env.test.ts | 90 ++++++++++++++++++++++++++++++++++ setup/systemd-user-env.ts | 38 ++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 setup/systemd-user-env.test.ts create mode 100644 setup/systemd-user-env.ts 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`, + }; +}