From 668a6f54fecb08e5ebf3cadef682c88a416de550 Mon Sep 17 00:00:00 2001 From: Syu <62822461+SYU8384@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:10:29 +0900 Subject: [PATCH] fix(web): update foreground systemd services safely (#2542) * fix(web): update foreground systemd services safely * clarify desktop and remote updates * Revert "clarify desktop and remote updates" This reverts commit aaf4516277de003e132d549d5281811fae96cfd5. * test-web-systemd-updates --- .../web/server/lib/opencode/DOCUMENTATION.md | 3 + .../server/lib/opencode/openchamber-routes.js | 69 ++++++++- .../lib/opencode/openchamber-routes.test.js | 141 ++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/opencode/openchamber-routes.test.js diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 3996860f..bbb5137f 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -343,6 +343,9 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints: - `GET /api/openchamber/update-check` - `POST /api/openchamber/update-install` + - Foreground servers running under a systemd user unit queue installation in + a separate transient unit and restart the configured service afterwards. + `OPENCHAMBER_SYSTEMD_UNIT` overrides the default `openchamber.service`. - `GET /api/openchamber/models-metadata` - `GET /api/zen/models` diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index 4c3d88f5..a618e731 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -1,3 +1,21 @@ +const SYSTEMD_SERVICE_UNIT_PATTERN = /^[A-Za-z0-9:_.@-]+\.service$/; + +function resolveSystemdServiceUnit(environment) { + if (!environment.INVOCATION_ID) { + return null; + } + + const configuredUnit = typeof environment.OPENCHAMBER_SYSTEMD_UNIT === 'string' + ? environment.OPENCHAMBER_SYSTEMD_UNIT.trim() + : ''; + const unit = configuredUnit || 'openchamber.service'; + return SYSTEMD_SERVICE_UNIT_PATTERN.test(unit) ? unit : null; +} + +function quotePosixShell(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + export const registerOpenChamberRoutes = (app, dependencies) => { const { fs, @@ -54,7 +72,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => { app.post('/api/openchamber/update-install', async (_req, res) => { try { - const { spawn: spawnChild } = await import('child_process'); + const { spawn: spawnChild, spawnSync } = await import('child_process'); const { checkForUpdates, getUpdateCommand, @@ -110,6 +128,55 @@ export const registerOpenChamberRoutes = (app, dependencies) => { } const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon'; const isForegroundService = launchMode === 'foreground'; + const systemdServiceUnit = isForegroundService ? resolveSystemdServiceUnit(process.env) : null; + + if (isForegroundService) { + if (!systemdServiceUnit) { + return res.status(409).json({ + error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.', + }); + } + + const updateJobName = `openchamber-update-${Date.now()}`; + const updateLogPath = `journalctl --user-unit ${updateJobName}.service`; + const updateScript = [ + 'set -eu', + updateCmd, + `systemctl --user restart ${quotePosixShell(systemdServiceUnit)}`, + ].join('\n'); + const systemdRun = spawnSync('systemd-run', [ + '--user', + `--unit=${updateJobName}`, + '--collect', + '--service-type=exec', + `--setenv=PATH=${process.env.PATH || ''}`, + '/bin/sh', + '-c', + updateScript, + ], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + + if (systemdRun.status !== 0) { + const detail = (systemdRun.stderr || systemdRun.stdout || '').trim(); + return res.status(409).json({ + error: detail || `Could not queue update job for ${systemdServiceUnit}`, + }); + } + + return res.json({ + success: true, + message: 'Update queued; OpenChamber will restart after installation completes', + version: updateInfo.version, + packageManager: pm, + autoRestart: true, + restartManager: 'systemd', + jobId: updateJobName, + logPath: updateLogPath, + }); + } const isWindows = process.platform === 'win32'; const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; diff --git a/packages/web/server/lib/opencode/openchamber-routes.test.js b/packages/web/server/lib/opencode/openchamber-routes.test.js new file mode 100644 index 00000000..6b5ac534 --- /dev/null +++ b/packages/web/server/lib/opencode/openchamber-routes.test.js @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import path from 'node:path'; +import request from 'supertest'; + +vi.mock('child_process', () => ({ + spawn: vi.fn(), + spawnSync: vi.fn(), +})); + +vi.mock('../package-manager.js', () => ({ + checkForUpdates: vi.fn(), + getUpdateCommand: vi.fn(), + detectPackageManagerDetails: vi.fn(), +})); + +const childProcess = await import('child_process'); +const packageManager = await import('../package-manager.js'); +const { registerOpenChamberRoutes } = await import('./openchamber-routes.js'); + +const createApp = ({ environment = {}, storedOptions = {} } = {}) => { + const app = express(); + const dependencies = { + fs: { + existsSync: vi.fn(() => false), + promises: { + readFile: vi.fn(async () => JSON.stringify({ + launchMode: 'foreground', + port: 7897, + ...storedOptions, + })), + }, + }, + path, + process: { + env: environment, + platform: 'linux', + execPath: '/usr/bin/node', + }, + server: { + address: () => ({ port: 7897 }), + }, + __dirname: '/opt/openchamber/server', + openchamberDataDir: '/tmp/openchamber', + modelsDevApiUrl: 'https://models.example.test', + modelsMetadataCacheTtl: 0, + readSettingsFromDiskMigrated: vi.fn(), + fetchFreeZenModels: vi.fn(), + getCachedZenModels: vi.fn(), + }; + + registerOpenChamberRoutes(app, dependencies); + return { app, dependencies }; +}; + +beforeEach(() => { + packageManager.checkForUpdates.mockResolvedValue({ + available: true, + version: '1.17.1', + }); + packageManager.detectPackageManagerDetails.mockReturnValue({ + packageManager: 'npm', + }); + packageManager.getUpdateCommand.mockReturnValue('npm install -g @openchamber/web@latest'); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('OpenChamber foreground update route', () => { + it('rejects a foreground update when the server is not owned by systemd', async () => { + const { app } = createApp(); + + await request(app) + .post('/api/openchamber/update-install') + .expect(409, { + error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.', + }); + + expect(childProcess.spawnSync).not.toHaveBeenCalled(); + }); + + it('rejects an unsafe systemd unit override before starting an update job', async () => { + const { app } = createApp({ + environment: { + INVOCATION_ID: 'systemd-invocation', + OPENCHAMBER_SYSTEMD_UNIT: 'openchamber.service; rm -rf /', + }, + }); + + await request(app) + .post('/api/openchamber/update-install') + .expect(409, { + error: 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.', + }); + + expect(childProcess.spawnSync).not.toHaveBeenCalled(); + }); + + it('queues the install in a transient systemd unit and returns its job identifier', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + childProcess.spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }); + const { app } = createApp({ + environment: { + INVOCATION_ID: 'systemd-invocation', + OPENCHAMBER_SYSTEMD_UNIT: 'openchamber@wsl.service', + PATH: '/home/syu/.npm-global/bin:/usr/bin:/bin', + }, + }); + + await request(app) + .post('/api/openchamber/update-install') + .expect(200, { + success: true, + message: 'Update queued; OpenChamber will restart after installation completes', + version: '1.17.1', + packageManager: 'npm', + autoRestart: true, + restartManager: 'systemd', + jobId: 'openchamber-update-1700000000000', + logPath: 'journalctl --user-unit openchamber-update-1700000000000.service', + }); + + expect(childProcess.spawnSync).toHaveBeenCalledWith('systemd-run', [ + '--user', + '--unit=openchamber-update-1700000000000', + '--collect', + '--service-type=exec', + '--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin', + '/bin/sh', + '-c', + "set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'", + ], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + }); +});