diff --git a/README.md b/README.md index 70e32a5..4dffd19 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ![menu](misc/menu.jpg) ## NOTICE +* V2.33 Security policy is applied. * V2.21 [cm-cli](docs/en/cm-cli.md) tool is added. * V2.18 to V2.18.3 is not functioning due to a severe bug. Users on these versions are advised to promptly update to V2.18.4. Please navigate to the `ComfyUI/custom_nodes/ComfyUI-Manager` directory and execute `git pull` to update. * You can see whole nodes info on [ComfyUI Nodes Info](https://ltdrdata.github.io/) page. @@ -337,13 +338,32 @@ When you run the `scan.sh` script: * Currently, `vid2vid` is not being updated, causing compatibility issues. * If you encounter the error message `Overlapped Object has pending operation at deallocation on Comfyui Manager load` under Windows * Edit `config.ini` file: add `windows_selector_event_loop_policy = True` -* How to disable unsecure features - * Edit `config.ini` file: add `disable_unsecure_features = True` - * If you disable unsecure features these features won't work - * install/uninstall/fix custom nodes - * restore/remove snapshot - * pip install - * reboot + + +## Security policy + * Edit `config.ini` file: add `security_level = ` + * `strong` + * doesn't allow `high` and `middle` level risky feature + * `normal` + * doesn't allow `high` level risky feature if `--listen` is specified and not starts with `127.` + * `middle` level risky feature is available + * `weak` + * all feature is available + + * `high` level risky features + * `Install via git url`, `pip install` + * Installation of custom nodes registered not in the `default channel`. + * Display terminal log + + * `middle` level risky features + * Uninstall/Update/Fix custom nodes + * Installation of custom nodes registered in the `default channel`. + * Restore/Remove Snapshot + * Restart + + * `low` level risky features + * Update ComfyUI + ## TODO: Unconventional form of custom node list diff --git a/glob/manager_core.py b/glob/manager_core.py index 7b77577..edc3d21 100644 --- a/glob/manager_core.py +++ b/glob/manager_core.py @@ -23,7 +23,7 @@ sys.path.append(glob_path) import cm_global from manager_util import * -version = [2, 32, 7] +version = [2, 33] version_str = f"V{version[0]}.{version[1]}" + (f'.{version[2]}' if len(version) > 2 else '') comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) @@ -205,7 +205,7 @@ def write_config(): 'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'], 'model_download_by_agent': get_config()['model_download_by_agent'], 'downgrade_blacklist': get_config()['downgrade_blacklist'], - 'disable_unsecure_features': get_config()['disable_unsecure_features'], + 'security_level': get_config()['security_level'], } with open(config_path, 'w') as configfile: config.write(configfile) @@ -217,6 +217,15 @@ def read_config(): config.read(config_path) default_conf = config['default'] + # policy migration: disable_unsecure_features -> security_level + if 'disable_unsecure_features' in default_conf: + if default_conf['disable_unsecure_features'].lower() == 'true': + security_level = 'strong' + else: + security_level = 'normal' + else: + security_level = default_conf['security_level'] if 'security_level' in default_conf else 'normal' + return { 'preview_method': default_conf['preview_method'] if 'preview_method' in default_conf else manager_funcs.get_current_preview_method(), 'badge_mode': default_conf['badge_mode'] if 'badge_mode' in default_conf else 'none', @@ -231,7 +240,7 @@ def read_config(): 'windows_selector_event_loop_policy': default_conf['windows_selector_event_loop_policy'].lower() == 'true' if 'windows_selector_event_loop_policy' in default_conf else False, 'model_download_by_agent': default_conf['model_download_by_agent'].lower() == 'true' if 'model_download_by_agent' in default_conf else False, 'downgrade_blacklist': default_conf['downgrade_blacklist'] if 'downgrade_blacklist' in default_conf else '', - 'disable_unsecure_features': default_conf['disable_unsecure_features'].lower() == 'true' if 'disable_unsecure_features' in default_conf else False, + 'security_level': security_level } except Exception: @@ -249,7 +258,7 @@ def read_config(): 'windows_selector_event_loop_policy': False, 'model_download_by_agent': False, 'downgrade_blacklist': '', - 'disable_unsecure_features': False, + 'security_level': 'normal', } @@ -1190,6 +1199,3 @@ def unzip(model_path): os.remove(model_path) return True - -def is_unsecure_features_disabled(): - return get_config()['disable_unsecure_features'] diff --git a/glob/manager_server.py b/glob/manager_server.py index 7076d0c..427adc8 100644 --- a/glob/manager_server.py +++ b/glob/manager_server.py @@ -42,6 +42,36 @@ from comfy.cli_args import args import latent_preview +is_local_mode = args.listen.startswith('127.') + + +def is_allowed_security_level(level): + if level == 'high': + if is_local_mode: + return core.get_config()['security_level'].lower() in ['weak', 'normal'] + else: + return core.get_config()['security_level'].lower() == 'weak' + elif level == 'middle': + return core.get_config()['security_level'].lower() in ['weak', 'normal'] + else: + return True + + +async def get_risky_level(files): + json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json') + json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://github.com/ltdrdata/ComfyUI-Manager/raw/main/custom-node-list.json') + + all_urls = set() + for x in json_data1['custom_nodes'] + json_data2['custom_nodes']: + all_urls.update(x['files']) + + for x in files: + if x not in all_urls: + return "high" + + return "middle" + + class ManagerFuncsInComfyUI(core.ManagerFuncs): def get_current_preview_method(self): if args.preview_method == latent_preview.LatentPreviewMethod.Auto: @@ -358,6 +388,10 @@ async def fetch_updates(request): @PromptServer.instance.routes.get("/customnode/update_all") async def update_all(request): + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) + try: core.save_snapshot_with_postfix('autosave') @@ -551,9 +585,9 @@ async def get_snapshot_list(request): @PromptServer.instance.routes.get("/snapshot/remove") async def remove_snapshot(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the remove feature. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) try: target = request.rel_url.query["target"] @@ -569,9 +603,9 @@ async def remove_snapshot(request): @PromptServer.instance.routes.get("/snapshot/restore") async def remove_snapshot(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the restore feature. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) try: target = request.rel_url.query["target"] @@ -737,12 +771,17 @@ def copy_set_active(files, is_disable, js_path_name='.'): @PromptServer.instance.routes.post("/customnode/install") async def install_custom_node(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the installation of custom nodes. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) json_data = await request.json() + risky_level = await get_risky_level(json_data['files']) + if not is_allowed_security_level(risky_level): + print(f"ERROR: This installation is not allowed in this security_level. Please contact the administrator.") + return web.Response(status=404) + install_type = json_data['install_type'] print(f"Install custom node '{json_data['title']}'") @@ -779,9 +818,9 @@ async def install_custom_node(request): @PromptServer.instance.routes.post("/customnode/fix") async def fix_custom_node(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the fix feature. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) json_data = await request.json() @@ -813,9 +852,9 @@ async def fix_custom_node(request): @PromptServer.instance.routes.post("/customnode/install/git_url") async def install_custom_node_git_url(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the installation of custom nodes. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('high'): + print(f"ERROR: To use this feature, you must set '--listen' to a local IP and set the security level to 'middle' or 'weak'. Please contact the administrator.") + return web.Response(status=403) url = await request.text() res = core.gitclone_install([url]) @@ -829,9 +868,9 @@ async def install_custom_node_git_url(request): @PromptServer.instance.routes.post("/customnode/install/pip") async def install_custom_node_git_url(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the installation of pip package. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('high'): + print(f"ERROR: To use this feature, you must set '--listen' to a local IP and set the security level to 'middle' or 'weak'. Please contact the administrator.") + return web.Response(status=403) packages = await request.text() core.pip_install(packages.split(' ')) @@ -841,9 +880,9 @@ async def install_custom_node_git_url(request): @PromptServer.instance.routes.post("/customnode/uninstall") async def uninstall_custom_node(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the uninstallation of custom nodes. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) json_data = await request.json() @@ -869,6 +908,10 @@ async def uninstall_custom_node(request): @PromptServer.instance.routes.post("/customnode/update") async def update_custom_node(request): + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) + json_data = await request.json() install_type = json_data['install_type'] @@ -983,9 +1026,9 @@ manager_terminal_hook = ManagerTerminalHook() @PromptServer.instance.routes.get("/manager/terminal") async def terminal_mode(request): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the terminal feature. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('high'): + print(f"ERROR: To use this action, a security_level of `weak` is required. Please contact the administrator.") + return web.Response(status=403) if "mode" in request.rel_url.query: if request.rel_url.query['mode'] == 'true': @@ -1110,9 +1153,9 @@ async def get_notice(request): @PromptServer.instance.routes.get("/manager/reboot") def restart(self): - if core.is_unsecure_features_disabled(): - print(f"ERROR: The unsecure feature is disabled, restricting the reboot feature. Please contact the administrator.") - return web.Response(status=400) + if not is_allowed_security_level('middle'): + print(f"ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.") + return web.Response(status=403) try: sys.stdout.close_log() diff --git a/js/a1111-alter-downloader.js b/js/a1111-alter-downloader.js index 65780a6..c78d9b2 100644 --- a/js/a1111-alter-downloader.js +++ b/js/a1111-alter-downloader.js @@ -56,7 +56,7 @@ export class AlternativesInstaller extends ComfyDialog { let data2 = data1.custom_node; if(!data2) - continue; + continue; let content = data1.tags.toLowerCase() + data1.description.toLowerCase() + data2.author.toLowerCase() + data2.description.toLowerCase() + data2.title.toLowerCase(); diff --git a/js/cm-api.js b/js/cm-api.js index e65cb34..1a6309d 100644 --- a/js/cm-api.js +++ b/js/cm-api.js @@ -41,9 +41,18 @@ async function tryInstallCustomNode(event) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event.detail.target) }); + + if(response.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return false; + } } - api.fetchApi("/manager/reboot"); + let response = await api.fetchApi("/manager/reboot"); + if(response.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return false; + } await sleep(300); diff --git a/js/comfyui-manager.js b/js/comfyui-manager.js index 3e98c23..ab23bdd 100644 --- a/js/comfyui-manager.js +++ b/js/comfyui-manager.js @@ -15,7 +15,7 @@ import { CustomNodesInstaller } from "./custom-nodes-downloader.js"; import { AlternativesInstaller } from "./a1111-alter-downloader.js"; import { SnapshotManager } from "./snapshot.js"; import { ModelInstaller } from "./model-downloader.js"; -import { manager_instance, setManagerInstance, install_via_git_url, install_pip, rebootAPI, free_models } from "./common.js"; +import { manager_instance, setManagerInstance, install_via_git_url, install_pip, rebootAPI, free_models, show_message } from "./common.js"; import { ComponentBuilderDialog, load_components, set_component_policy, getPureName } from "./components-manager.js"; import { set_double_click_policy } from "./node_fixer.js"; @@ -520,25 +520,21 @@ async function updateComfyUI() { const response = await api.fetchApi('/comfyui_manager/update_comfyui'); if (response.status == 400) { - app.ui.dialog.show('Failed to update ComfyUI.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('Failed to update ComfyUI.'); return false; } if (response.status == 201) { - app.ui.dialog.show('ComfyUI has been successfully updated.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('ComfyUI has been successfully updated.'); } else { - app.ui.dialog.show('ComfyUI is already up to date with the latest version.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('ComfyUI is already up to date with the latest version.'); } return true; } catch (exception) { - app.ui.dialog.show(`Failed to update ComfyUI / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Failed to update ComfyUI / ${exception}`); return false; } finally { @@ -560,13 +556,12 @@ async function fetchUpdates(update_check_checkbox) { const response = await api.fetchApi(`/customnode/fetch_updates?mode=${mode}`); if (response.status != 200 && response.status != 201) { - app.ui.dialog.show('Failed to fetch updates.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('Failed to fetch updates.'); return false; } if (response.status == 201) { - app.ui.dialog.show("There is an updated extension available.

NOTE:
Fetch Updates is not an update.
Please update from

"); + show_message("There is an updated extension available.

NOTE:
Fetch Updates is not an update.
Please update from

"); const button = document.getElementById('cm-install-customnodes-button'); button.addEventListener("click", @@ -580,19 +575,16 @@ async function fetchUpdates(update_check_checkbox) { } ); - app.ui.dialog.element.style.zIndex = 10010; update_check_checkbox.checked = false; } else { - app.ui.dialog.show('All extensions are already up-to-date with the latest versions.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('All extensions are already up-to-date with the latest versions.'); } return true; } catch (exception) { - app.ui.dialog.show(`Failed to update custom nodes / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Failed to update custom nodes / ${exception}`); return false; } finally { @@ -615,11 +607,16 @@ async function updateAll(update_check_checkbox, manager_dialog) { const response1 = await api.fetchApi('/comfyui_manager/update_comfyui'); const response2 = await api.fetchApi(`/customnode/update_all?mode=${mode}`); + if (response2.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return false; + } + if (response1.status == 400 || response2.status == 400) { - app.ui.dialog.show('Failed to update ComfyUI or several extensions.

See terminal log.
'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('Failed to update ComfyUI or several extensions.

See terminal log.
'); return false; } + if(response1.status == 201 || response2.status == 201) { const update_info = await response2.json(); @@ -633,7 +630,7 @@ async function updateAll(update_check_checkbox, manager_dialog) { updated_list = "
UPDATED: "+update_info.updated.join(", "); } - app.ui.dialog.show( + show_message( "ComfyUI and all extensions have been updated to the latest version.
To apply the updated custom node, please ComfyUI. And refresh browser.
" +failed_list +updated_list @@ -646,19 +643,15 @@ async function updateAll(update_check_checkbox, manager_dialog) { manager_dialog.close(); } }); - - app.ui.dialog.element.style.zIndex = 10010; } else { - app.ui.dialog.show('ComfyUI and all extensions are already up-to-date with the latest versions.'); - app.ui.dialog.element.style.zIndex = 10010; + show_message('ComfyUI and all extensions are already up-to-date with the latest versions.'); } return true; } catch (exception) { - app.ui.dialog.show(`Failed to update ComfyUI or several extensions / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Failed to update ComfyUI or several extensions / ${exception}`); return false; } finally { diff --git a/js/common.js b/js/common.js index 0361526..9fb7a18 100644 --- a/js/common.js +++ b/js/common.js @@ -1,6 +1,11 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +export function show_message(msg) { + app.ui.dialog.show(msg); + app.ui.dialog.element.style.zIndex = 10010; +} + export async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -47,6 +52,20 @@ export async function install_checked_custom_node(grid_rows, target_i, caller, m body: JSON.stringify(target) }); + if(response.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + caller.updateMessage(''); + await caller.invalidateControl(); + return; + } + + if(response.status == 404) { + show_message('With the current security level configuration, only custom nodes from the "default channel" can be installed.'); + caller.updateMessage(''); + await caller.invalidateControl(); + return; + } + if(response.status == 400) { show_message(`${mode} failed: ${target.title}`); continue; @@ -94,19 +113,21 @@ export async function install_pip(packages) { body: packages, }); + if(res.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return; + } + if(res.status == 200) { - app.ui.dialog.show(`PIP package installation is processed.
To apply the pip packages, please click the button in ComfyUI.`); + show_message(`PIP package installation is processed.
To apply the pip packages, please click the button in ComfyUI.`); const rebootButton = document.getElementById('cm-reboot-button3'); const self = this; rebootButton.addEventListener("click", rebootAPI); - - app.ui.dialog.element.style.zIndex = 10010; } else { - app.ui.dialog.show(`Failed to install '${packages}'
See terminal log.`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Failed to install '${packages}'
See terminal log.`); } } @@ -116,21 +137,24 @@ export async function install_via_git_url(url, manager_dialog) { } if(!isValidURL(url)) { - app.ui.dialog.show(`Invalid Git url '${url}'`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Invalid Git url '${url}'`); return; } - app.ui.dialog.show(`Wait...

Installing '${url}'`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Wait...

Installing '${url}'`); const res = await api.fetchApi("/customnode/install/git_url", { method: "POST", body: url, }); + if(res.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return; + } + if(res.status == 200) { - app.ui.dialog.show(`'${url}' is installed
To apply the installed custom node, please ComfyUI.`); + show_message(`'${url}' is installed
To apply the installed custom node, please ComfyUI.`); const rebootButton = document.getElementById('cm-reboot-button4'); const self = this; @@ -141,12 +165,9 @@ export async function install_via_git_url(url, manager_dialog) { manager_dialog.close(); } }); - - app.ui.dialog.element.style.zIndex = 10010; } else { - app.ui.dialog.show(`Failed to install '${url}'
See terminal log.`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Failed to install '${url}'
See terminal log.`); } } @@ -158,15 +179,9 @@ export async function free_models() { }); if(res.status == 200) { - app.ui.dialog.show('Models have been unloaded.') + show_message('Models have been unloaded.') } else { - app.ui.dialog.show('Unloading of models failed.

Installed ComfyUI may be an outdated version.') + show_message('Unloading of models failed.

Installed ComfyUI may be an outdated version.') } - app.ui.dialog.element.style.zIndex = 10010; } - -export function show_message(msg) { - app.ui.dialog.show(msg); - app.ui.dialog.element.style.zIndex = 10010; -} \ No newline at end of file diff --git a/js/model-downloader.js b/js/model-downloader.js index 9642bcd..0f0d1a8 100644 --- a/js/model-downloader.js +++ b/js/model-downloader.js @@ -1,7 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js" import { ComfyDialog, $el } from "../../scripts/ui.js"; -import { install_checked_custom_node, manager_instance, rebootAPI } from "./common.js"; +import { install_checked_custom_node, manager_instance, rebootAPI, show_message } from "./common.js"; async function install_model(target) { if(ModelInstaller.instance) { @@ -20,8 +20,7 @@ async function install_model(target) { return true; } catch(exception) { - app.ui.dialog.show(`Install failed: ${target.title} / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Install failed: ${target.title} / ${exception}`); return false; } finally { diff --git a/js/snapshot.js b/js/snapshot.js index 0ca7c76..01e544f 100644 --- a/js/snapshot.js +++ b/js/snapshot.js @@ -1,24 +1,28 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js" import { ComfyDialog, $el } from "../../scripts/ui.js"; -import { manager_instance, rebootAPI } from "./common.js"; +import { manager_instance, rebootAPI, show_message } from "./common.js"; async function restore_snapshot(target) { if(SnapshotManager.instance) { try { const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" }); + + if(response.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return false; + } + if(response.status == 400) { - app.ui.dialog.show(`Restore snapshot failed: ${target.title} / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Restore snapshot failed: ${target.title} / ${exception}`); } app.ui.dialog.close(); return true; } catch(exception) { - app.ui.dialog.show(`Restore snapshot failed: ${target.title} / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Restore snapshot failed: ${target.title} / ${exception}`); return false; } finally { @@ -32,17 +36,21 @@ async function remove_snapshot(target) { if(SnapshotManager.instance) { try { const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" }); + + if(response.status == 403) { + show_message('This action is not allowed with this security level configuration.'); + return false; + } + if(response.status == 400) { - app.ui.dialog.show(`Remove snapshot failed: ${target.title} / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Remove snapshot failed: ${target.title} / ${exception}`); } app.ui.dialog.close(); return true; } catch(exception) { - app.ui.dialog.show(`Restore snapshot failed: ${target.title} / ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Restore snapshot failed: ${target.title} / ${exception}`); return false; } finally { @@ -58,8 +66,7 @@ async function save_current_snapshot() { return true; } catch(exception) { - app.ui.dialog.show(`Backup snapshot failed: ${exception}`); - app.ui.dialog.element.style.zIndex = 10010; + show_message(`Backup snapshot failed: ${exception}`); return false; } finally {