Compare commits

...

1 Commits

Author SHA1 Message Date
glifocat 1512e3f19e fix(setup): re-probe systemd user session with derived env on su- entry
`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/<uid>` 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
2026-05-15 17:16:12 +02:00
3 changed files with 151 additions and 0 deletions
+23
View File
@@ -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<void> {
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;
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { computeUserSystemdEnv } from './systemd-user-env.js';
function existsFrom(paths: Set<string>) {
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/<uid> 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');
});
});
+38
View File
@@ -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`,
};
}