File size: 6,404 Bytes
fc93158 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveDefaultGatewayPort } from "../../config/port-defaults.js";
import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../../daemon/constants.js";
/**
* Shell-escape a string for embedding in single-quoted shell arguments.
* Replaces every `'` with `'\''` (end quote, escaped quote, resume quote).
* For batch scripts, validates against special characters instead.
*/
function shellEscape(value: string): string {
return value.replace(/'/g, "'\\''");
}
/** Validates a string is safe for embedding in a batch (cmd.exe) script. */
function isBatchSafe(value: string): boolean {
// Reject characters that have special meaning in batch: & | < > ^ % " ` $
return /^[A-Za-z0-9 _\-().]+$/.test(value);
}
function resolveSystemdUnit(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_SYSTEMD_UNIT?.trim();
if (override) {
return override.endsWith(".service") ? override : `${override}.service`;
}
return `${resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE)}.service`;
}
function resolveLaunchdLabel(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_LAUNCHD_LABEL?.trim();
if (override) {
return override;
}
return resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
}
function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string {
const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim();
if (override) {
return override;
}
return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE);
}
/**
* Prepares a standalone script to restart the gateway service.
* This script is written to a temporary directory and does not depend on
* the installed package files, ensuring restart capability even if the
* update process temporarily removes or corrupts installation files.
*/
export async function prepareRestartScript(
env: NodeJS.ProcessEnv = process.env,
gatewayPort: number = resolveDefaultGatewayPort(),
): Promise<string | null> {
const tmpDir = os.tmpdir();
const timestamp = Date.now();
const platform = process.platform;
let scriptContent = "";
let filename = "";
try {
if (platform === "linux") {
const unitName = resolveSystemdUnit(env);
const escaped = shellEscape(unitName);
filename = `openclaw-restart-${timestamp}.sh`;
scriptContent = `#!/bin/sh
# Standalone restart script — survives parent process termination.
# Wait briefly to ensure file locks are released after update.
sleep 1
systemctl --user restart '${escaped}'
# Self-cleanup
rm -f "$0"
`;
} else if (platform === "darwin") {
const label = resolveLaunchdLabel(env);
const escaped = shellEscape(label);
// Fallback to 501 if getuid is not available (though it should be on macOS)
const uid = process.getuid ? process.getuid() : 501;
// Resolve HOME at generation time via env/process.env to match launchd.ts,
// and shell-escape the label in the plist filename to prevent injection.
const home = env.HOME?.trim() || process.env.HOME || os.homedir();
const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
const escapedPlistPath = shellEscape(plistPath);
filename = `openclaw-restart-${timestamp}.sh`;
scriptContent = `#!/bin/sh
# Standalone restart script — survives parent process termination.
# Wait briefly to ensure file locks are released after update.
sleep 1
# Try kickstart first (works when the service is still registered).
# If it fails (e.g. after bootout), clear any persisted disabled state,
# then re-register via bootstrap and kickstart.
if ! launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null; then
launchctl enable 'gui/${uid}/${escaped}' 2>/dev/null
launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}' 2>/dev/null
launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null || true
fi
# Self-cleanup
rm -f "$0"
`;
} else if (platform === "win32") {
const taskName = resolveWindowsTaskName(env);
if (!isBatchSafe(taskName)) {
return null;
}
const port =
Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : resolveDefaultGatewayPort();
filename = `openclaw-restart-${timestamp}.bat`;
scriptContent = `@echo off
REM Standalone restart script — survives parent process termination.
REM Wait briefly to ensure file locks are released after update.
timeout /t 2 /nobreak >nul
schtasks /End /TN "${taskName}"
REM Poll for gateway port release before rerun; force-kill listener if stuck.
set /a attempts=0
:wait_for_port_release
set /a attempts+=1
netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul
if errorlevel 1 goto port_released
if %attempts% GEQ 10 goto force_kill_listener
timeout /t 1 /nobreak >nul
goto wait_for_port_release
:force_kill_listener
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do (
taskkill /F /PID %%P >nul 2>&1
goto port_released
)
:port_released
schtasks /Run /TN "${taskName}"
REM Self-cleanup
del "%~f0"
`;
} else {
return null;
}
const scriptPath = path.join(tmpDir, filename);
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 });
return scriptPath;
} catch {
// If we can't write the script, we'll fall back to the standard restart method
return null;
}
}
/**
* Executes the prepared restart script as a **detached** process.
*
* The script must outlive the CLI process because the CLI itself is part
* of the service being restarted — `systemctl restart` / `launchctl
* kickstart -k` will terminate the current process tree. Using
* `spawn({ detached: true })` + `unref()` ensures the script survives
* the parent's exit.
*
* Resolves immediately after spawning; the script runs independently.
*/
export async function runRestartScript(scriptPath: string): Promise<void> {
const isWindows = process.platform === "win32";
const file = isWindows ? "cmd.exe" : "/bin/sh";
const args = isWindows ? ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)] : [scriptPath];
const child = spawn(file, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();
}
|