From 3a96c7ce649b3040696dbac92fa249d497dd0229 Mon Sep 17 00:00:00 2001 From: Ibrahim Khan <2005ibrahimkhan@gmail.com> Date: Sat, 5 Sep 2026 09:12:43 -0600 Subject: [PATCH] fix(cli): warn when systemd user lingering is disabled (#1855) --- packages/web/bin/cli.test.js | 92 ++++++++++++++++++++++++ packages/web/bin/lib/cli-startup.js | 50 +++++++++++++ packages/web/bin/lib/commands-startup.js | 47 ++++++++++-- 3 files changed, 183 insertions(+), 6 deletions(-) diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index d6198707..f8597844 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -11,6 +11,7 @@ import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js'; import { requestJson } from './lib/cli-http.js'; import { requestControlAction } from './lib/cli-control.js'; import { inspectTunnelAttachability } from './lib/cli-lifecycle.js'; +import { startupCommand } from './lib/commands-startup.js'; import { formatGoal } from './lib/commands-schedule.js'; import { buildSessionCreatePayload, @@ -34,6 +35,7 @@ import { discoverRunningInstances, discoverUnconfirmedRegistryInstanceOnPort, ensureTunnelProfilesMigrated, + EXIT_CODE, generateUiPassword, getInstanceFilePath, getPidFilePath, @@ -1456,3 +1458,93 @@ describe('Windows startup task command builder', () => { expect(cmd).not.toContain('-Command '); }); }); + +describe('startup command lingering output', () => { + const linuxStatus = (lingerEnabled, lingerUser = 'alice') => ({ + supported: true, + platform: 'linux', + enabled: true, + active: true, + activeState: 'active', + servicePath: '/home/alice/.config/systemd/user/openchamber.service', + lingerEnabled, + lingerUser, + }); + + const dependenciesFor = (status) => ({ + getStartupStatus: () => status, + enableStartupService: () => status, + disableStartupService: () => status, + }); + + const runCommand = (status, options, action) => startupCommand(options, action, dependenciesFor(status)); + + it.each([ + ['enable', true, 'ok', undefined], + ['enable', false, 'warning', 'LINGER_DISABLED'], + ['enable', null, 'warning', 'LINGER_UNKNOWN'], + ['status', true, 'ok', undefined], + ['status', false, 'warning', 'LINGER_DISABLED'], + ['status', null, 'warning', 'LINGER_UNKNOWN'], + ])('reports %s with Linux linger=%s as JSON-only output', async (action, lingerEnabled, expectedStatus, warningCode) => { + const output = await captureStdout(() => runCommand(linuxStatus(lingerEnabled), { json: true }, action)); + const payload = JSON.parse(output); + + expect(payload.status).toBe(expectedStatus); + expect(payload.action).toBe(action); + expect(payload.lingerEnabled).toBe(lingerEnabled); + expect(payload.messages?.[0]?.code).toBe(warningCode); + }); + + it.each([ + ['enable', true, 'yes'], + ['enable', false, 'no'], + ['enable', null, 'unknown'], + ['status', true, 'yes'], + ['status', false, 'no'], + ['status', null, 'unknown'], + ])('reports %s with Linux linger=%s in one quiet result line', async (action, lingerEnabled, label) => { + const output = await captureStdout(() => runCommand(linuxStatus(lingerEnabled), { quiet: true }, action)); + + expect(output.split('\n')).toHaveLength(2); + expect(output).toContain(` linger:${label}\n`); + expect(output).not.toContain('loginctl'); + }); + + it.each([true, false])('warns with an actionable command in human TTY=%s output', async (isTTY) => { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: isTTY }); + try { + const output = await captureStdout(() => runCommand(linuxStatus(false), {}, 'enable')); + + expect(output).toContain('[LINGER_DISABLED]'); + expect(output).toContain('sudo loginctl enable-linger alice'); + } finally { + if (descriptor) Object.defineProperty(process.stdout, 'isTTY', descriptor); + else delete process.stdout.isTTY; + } + }); + + it('reports unknown state without inventing a user when detection is unavailable', async () => { + const output = await captureStdout(() => runCommand(linuxStatus(null, null), {}, 'status')); + + expect(output).toContain('[LINGER_UNKNOWN]'); + expect(output).toContain('loginctl show-user "$USER" -p Linger'); + }); + + it('reports disabled-service linger state without warning or remediation', async () => { + const output = await captureStdout(() => runCommand({ ...linuxStatus(false), enabled: false }, {}, 'status')); + + expect(output).toContain('user lingering is disabled'); + expect(output).not.toContain('[LINGER_DISABLED]'); + expect(output).not.toContain('loginctl enable-linger'); + }); + + it('does not emit linger guidance for unsupported systems', async () => { + await expect(runCommand( + { supported: false, platform: 'freebsd', enabled: false, servicePath: null }, + { json: true }, + 'status' + )).rejects.toMatchObject({ exitCode: EXIT_CODE.USAGE_ERROR }); + }); +}); diff --git a/packages/web/bin/lib/cli-startup.js b/packages/web/bin/lib/cli-startup.js index a7a5f929..70cdbe46 100644 --- a/packages/web/bin/lib/cli-startup.js +++ b/packages/web/bin/lib/cli-startup.js @@ -283,6 +283,53 @@ function runStartupCommand(command, args, options = {}) { return result; } +function parseLingerState(stdout) { + if (typeof stdout !== 'string') { + return null; + } + const match = stdout.match(/^Linger=([A-Za-z]+)\s*$/m); + if (!match) { + return null; + } + const value = match[1].toLowerCase(); + if (value === 'yes') return true; + if (value === 'no') return false; + return null; +} + +function getCurrentUsername() { + try { + const name = os.userInfo().username; + if (typeof name === 'string' && name.length > 0) { + return name; + } + } catch { + // userInfo() can throw when the uid has no passwd entry; fall back to env. + } + return process.env.USER || process.env.LOGNAME || ''; +} + +// A systemd --user service only keeps running without an active login session +// when the user has lingering enabled. Detect it so `startup enable` can warn +// that the service may otherwise stop on logout. Returns null when the state +// cannot be determined (no username, loginctl unavailable, or odd output). +function getUserLingerEnabled(username) { + const user = typeof username === 'string' && username.length > 0 ? username : getCurrentUsername(); + if (!user) { + return null; + } + let result; + try { + result = runStartupCommand('loginctl', ['show-user', user, '-p', 'Linger'], { allowFailure: true }); + } catch { + return null; + } + if (result.status !== 0) { + return null; + } + return parseLingerState(result.stdout); +} + function getStartupStatus() { const paths = getStartupServicePaths(); if (!paths.servicePath) { @@ -296,6 +343,7 @@ function getStartupStatus() { const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true }); const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true }); const activeState = (activeResult.stdout || '').trim() || 'inactive'; + const lingerUser = getCurrentUsername(); return { supported: true, platform: paths.platform, @@ -303,6 +351,8 @@ function getStartupStatus() { active: activeState === 'active', activeState, servicePath: paths.servicePath, + lingerEnabled: getUserLingerEnabled(lingerUser), + lingerUser: lingerUser || null, }; } return { diff --git a/packages/web/bin/lib/commands-startup.js b/packages/web/bin/lib/commands-startup.js index 8704679d..b1e16e37 100644 --- a/packages/web/bin/lib/commands-startup.js +++ b/packages/web/bin/lib/commands-startup.js @@ -9,7 +9,10 @@ import { logStatus, } from '../cli-output.js'; -async function startupCommand(options, action = 'status') { +async function startupCommand(options, action = 'status', dependencies = {}) { + const getStatus = dependencies.getStartupStatus || getStartupStatus; + const enableService = dependencies.enableStartupService || enableStartupService; + const disableService = dependencies.disableStartupService || disableStartupService; const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status'; if (!['status', 'enable', 'disable'].includes(normalized)) { throw new TunnelCliError( @@ -20,14 +23,14 @@ async function startupCommand(options, action = 'status') { let status; if (normalized === 'enable') { - status = enableStartupService(options); + status = enableService(options); } else if (normalized === 'disable') { - status = disableStartupService(); + status = disableService(); } else { - status = getStartupStatus(); + status = getStatus(); } - const result = { action: normalized, ...status }; + let result = { action: normalized, ...status }; if (!result.supported) { throw new TunnelCliError( `Startup integration is not supported on ${result.platform}.`, @@ -40,13 +43,30 @@ async function startupCommand(options, action = 'status') { EXIT_CODE.GENERAL_ERROR ); } + if (result.platform === 'linux' && result.enabled && result.lingerEnabled !== true) { + const user = result.lingerUser || '"$USER"'; + const disabled = result.lingerEnabled === false; + result = { + ...result, + messages: [{ + level: 'warning', + code: disabled ? 'LINGER_DISABLED' : 'LINGER_UNKNOWN', + message: disabled + ? `User lingering is disabled; the startup service may stop after logout. Run \`sudo loginctl enable-linger ${user}\` to keep it running.` + : `Could not verify user lingering; the startup service may stop after logout. Check with \`loginctl show-user ${user} -p Linger\`.`, + }], + }; + } if (isJsonMode(options)) { printJson(result); return; } if (isQuietMode(options)) { - process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`); + const lingerToken = result.platform === 'linux' + ? ` linger:${result.lingerEnabled === true ? 'yes' : result.lingerEnabled === false ? 'no' : 'unknown'}` + : ''; + process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}${lingerToken}\n`); return; } @@ -58,6 +78,21 @@ async function startupCommand(options, action = 'status') { if (normalized === 'enable') { logStatus('info', 'service command', 'openchamber serve --foreground'); } + if (result.platform === 'linux') { + if (result.lingerEnabled === true) { + logStatus('success', 'user lingering enabled'); + } else if (result.lingerEnabled === false) { + logStatus(result.enabled ? 'warning' : 'info', `${result.enabled ? '[LINGER_DISABLED] ' : ''}user lingering is disabled`, result.enabled ? 'startup service may stop after logout' : undefined); + if (result.enabled) { + logStatus('info', '[ENABLE_LINGER]', `sudo loginctl enable-linger ${result.lingerUser || '"$USER"'}`); + } + } else { + logStatus(result.enabled ? 'warning' : 'info', result.enabled ? '[LINGER_UNKNOWN] could not verify user lingering' : 'user lingering state is unknown', result.enabled ? 'startup service may stop after logout' : undefined); + if (result.enabled) { + logStatus('info', '[CHECK_LINGER]', `loginctl show-user ${result.lingerUser || '"$USER"'} -p Linger`); + } + } + } clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`); }