harden update restart script creation [AI] (#84088)

* fix: harden update restart script creation

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-19 21:05:37 +05:30
committed by GitHub
parent d0f7c8fa28
commit 48acdd3d85
3 changed files with 76 additions and 3 deletions
+1
View File
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.
- Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.
- CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`.
- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)
+61
View File
@@ -34,6 +34,11 @@ describe("restart-helper", () => {
throw error;
}
});
await fs.rmdir(path.dirname(scriptPath)).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
}
async function makeTempDir(prefix: string) {
@@ -135,9 +140,63 @@ exit 0
expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'");
// Script should self-cleanup
expect(content).toContain('rm -f "$0"');
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
await cleanupScript(scriptPath);
});
it("creates restart scripts in a private temp directory with exclusive creation", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const timestamp = 1_727_201_234_567;
const oldCandidatePath = path.join(os.tmpdir(), `openclaw-restart-${timestamp}.sh`);
const victimDir = await makeTempDir("openclaw-restart-helper-victim-");
const victimPath = path.join(victimDir, "restart.sh");
await fs.rm(oldCandidatePath, { force: true });
await fs.writeFile(victimPath, "preexisting script\n", "utf-8");
let candidateIsSymlink = false;
try {
await fs.symlink(victimPath, oldCandidatePath);
candidateIsSymlink = true;
} catch {
await fs.writeFile(oldCandidatePath, "preexisting script\n", { flag: "wx" });
}
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(timestamp);
const writeFileSpy = vi.spyOn(fs, "writeFile");
try {
const { scriptPath } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
});
const scriptDir = path.dirname(scriptPath);
const relativeScriptDir = path.relative(os.tmpdir(), scriptDir);
expect(scriptPath).not.toBe(oldCandidatePath);
expect(scriptDir).not.toBe(os.tmpdir());
expect(relativeScriptDir).not.toBe("");
expect(relativeScriptDir.startsWith("..")).toBe(false);
expect(path.isAbsolute(relativeScriptDir)).toBe(false);
expect(path.basename(scriptDir)).toMatch(/^openclaw-restart-/);
expect(writeFileSpy).toHaveBeenLastCalledWith(
scriptPath,
expect.any(String),
expect.objectContaining({ flag: "wx", mode: 0o755 }),
);
await expect(fs.readFile(victimPath, "utf-8")).resolves.toBe("preexisting script\n");
if (!candidateIsSymlink) {
await expect(fs.readFile(oldCandidatePath, "utf-8")).resolves.toBe(
"preexisting script\n",
);
}
await cleanupScript(scriptPath);
} finally {
dateSpy.mockRestore();
writeFileSpy.mockRestore();
await fs.rm(oldCandidatePath, { force: true });
await fs.rm(victimDir, { recursive: true, force: true });
}
});
it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const { scriptPath, content } = await prepareAndReadScript({
@@ -203,6 +262,7 @@ exit 1
expect(content).toContain("launchctl bootstrap 'gui/501'");
expect(content).toContain("Bootstrap loads RunAtLoad agents");
expect(content).toContain('rm -f "$0"');
expect(content).toContain('rmdir "$script_dir" 2>/dev/null || true');
await cleanupScript(scriptPath);
});
@@ -379,6 +439,7 @@ exit 0
expect(content).toContain("openclaw restart launched startup fallback");
expectWindowsRestartWaitOrdering(content);
expect(content).toContain('del "%~f0" >nul 2>&1');
expect(content).toContain('rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1');
await cleanupScript(scriptPath);
});
+14 -3
View File
@@ -68,7 +68,6 @@ export async function prepareRestartScript(
env: NodeJS.ProcessEnv = process.env,
gatewayPort: number = DEFAULT_GATEWAY_PORT,
): Promise<string | null> {
const tmpDir = os.tmpdir();
const timestamp = Date.now();
const platform = process.platform;
@@ -110,8 +109,10 @@ else
fi
fi
# Self-cleanup
script_dir=$(dirname "$0")
exec 3>&-
rm -f "$0"
rmdir "$script_dir" 2>/dev/null || true
exit "$status"
`;
} else if (platform === "darwin") {
@@ -157,7 +158,9 @@ else
printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
fi
# Self-cleanup (log is retained under the OpenClaw state logs directory).
script_dir=$(dirname "$0")
rm -f "$0"
rmdir "$script_dir" 2>/dev/null || true
exit "$status"
`;
} else if (platform === "win32") {
@@ -177,9 +180,11 @@ REM Keep this as a cmd wrapper so Group Policy script execution policies
REM cannot block the update restart handoff before schtasks.exe runs.
setlocal
set "OPENCLAW_RESTART_SCRIPT=%~f0"
set "OPENCLAW_RESTART_SCRIPT_DIR=%~dp0."
powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:OPENCLAW_RESTART_SCRIPT; $s=Get-Content -Raw -LiteralPath $p; $m='# POWERSHELL'; $i=$s.IndexOf($m); if ($i -lt 0) { exit 1 }; Invoke-Expression $s.Substring($i)"
set "status=%ERRORLEVEL%"
del "%~f0" >nul 2>&1
rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1
exit /b %status%
# POWERSHELL
# Wait briefly to ensure file locks are released after update.
@@ -370,8 +375,14 @@ exit $status
return null;
}
const scriptPath = path.join(tmpDir, filename);
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 });
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-"));
const scriptPath = path.join(scriptDir, filename);
try {
await fs.writeFile(scriptPath, scriptContent, { mode: 0o755, flag: "wx" });
} catch (error) {
await fs.rm(scriptDir, { recursive: true, force: true }).catch(() => {});
throw error;
}
return scriptPath;
} catch {
// If we can't write the script, we'll fall back to the standard restart method