fix(ux): MCP tool errors set isError; add `sibyl migrate --force`
Browse filesbugflow 2026-06-05 (UX focus). Two reporter-sourced gaps, both verified.
sibyl-memory-mcp 0.1.6 -> 0.1.7
_err() returned a plain dict, so FastMCP delivered tool errors as SUCCESSFUL
results (isError:false) with the error nested inside the payload. Agents
keying off the protocol-level isError flag could not detect failures. _err()
now raises ToolError carrying the same structured payload as JSON
(isError:true; error/code/recovery/upgrade_url preserved). Tool signatures
unchanged; only the error envelope is corrected.
sibyl-memory-cli 0.3.11 -> 0.3.12
`sibyl migrate` had no --force flag, so a detected harness already holding a
non-sibyl memory provider dead-ended at "Use --force to overwrite." with no
flag to pass. Added --force, threaded cli -> run_guided_setup(force=) ->
wire(force=force).
Tests: 34 green (16 mcp + 4 new mcp regression + migrate suite + 2 new cli
force-threading). 10 pre-existing CodexWirer/ClaudeWirer config-format failures
in setup.py are unrelated (that file is untouched by this change).
- sibyl-memory-cli/CHANGELOG.md +13 -0
- sibyl-memory-cli/pyproject.toml +1 -1
- sibyl-memory-cli/src/sibyl_memory_cli/cli.py +6 -1
- sibyl-memory-cli/src/sibyl_memory_cli/migrate.py +2 -2
- sibyl-memory-cli/tests/test_migrate_force_2026_06_05.py +67 -0
- sibyl-memory-mcp/CHANGELOG.md +14 -0
- sibyl-memory-mcp/pyproject.toml +1 -1
- sibyl-memory-mcp/src/sibyl_memory_mcp/server.py +13 -4
- sibyl-memory-mcp/tests/test_err_toolerror_2026_06_05.py +54 -0
|
@@ -4,6 +4,19 @@ All notable changes to `sibyl-memory-cli` are recorded here. Format follows
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## [0.3.11] — 2026-06-01
|
| 8 |
|
| 9 |
### Fixed
|
|
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.3.12] — 2026-06-05
|
| 8 |
+
|
| 9 |
+
### Fixed
|
| 10 |
+
|
| 11 |
+
- **`sibyl migrate --force` (onboarding dead-end).** When a detected harness
|
| 12 |
+
already had a non-sibyl memory provider, the wirer refused with
|
| 13 |
+
"Use --force to overwrite." but `run_guided_setup` called `wire()` with no
|
| 14 |
+
`force`, and `sibyl migrate` had no `--force` flag to pass, leaving the user
|
| 15 |
+
with no way forward. Added `--force` to `sibyl migrate`, threaded through
|
| 16 |
+
`cmd_migrate` → `run_guided_setup(force=)` → `wire(force=force)`, so migration
|
| 17 |
+
can overwrite an existing provider when the user explicitly opts in.
|
| 18 |
+
Regression coverage: `tests/test_migrate_force_2026_06_05.py`. (bugflow)
|
| 19 |
+
|
| 20 |
## [0.3.11] — 2026-06-01
|
| 21 |
|
| 22 |
### Fixed
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-cli"
|
| 7 |
-
version = "0.3.
|
| 8 |
description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
|
| 9 |
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 10 |
license = { text = "MIT" }
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-cli"
|
| 7 |
+
version = "0.3.12"
|
| 8 |
description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
|
| 9 |
authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
|
| 10 |
license = { text = "MIT" }
|
|
@@ -1056,7 +1056,7 @@ def cmd_migrate(args: argparse.Namespace) -> int:
|
|
| 1056 |
io = _migrate_io()
|
| 1057 |
report = M.run_guided_setup(
|
| 1058 |
home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
|
| 1059 |
-
io=io, debloat=not args.no_debloat,
|
| 1060 |
)
|
| 1061 |
|
| 1062 |
ph = report.get("phases", {})
|
|
@@ -1196,6 +1196,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 1196 |
"--yes", "-y", action="store_true",
|
| 1197 |
help="Skip the initial confirm (the trim step still always asks separately)",
|
| 1198 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1199 |
p_migrate.set_defaults(func=cmd_migrate)
|
| 1200 |
|
| 1201 |
return p
|
|
|
|
| 1056 |
io = _migrate_io()
|
| 1057 |
report = M.run_guided_setup(
|
| 1058 |
home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
|
| 1059 |
+
io=io, debloat=not args.no_debloat, force=getattr(args, "force", False),
|
| 1060 |
)
|
| 1061 |
|
| 1062 |
ph = report.get("phases", {})
|
|
|
|
| 1196 |
"--yes", "-y", action="store_true",
|
| 1197 |
help="Skip the initial confirm (the trim step still always asks separately)",
|
| 1198 |
)
|
| 1199 |
+
p_migrate.add_argument(
|
| 1200 |
+
"--force", action="store_true",
|
| 1201 |
+
help="Overwrite an existing non-sibyl memory provider when wiring a harness "
|
| 1202 |
+
"(without this, migrate stops at that harness and tells you to re-run with --force)",
|
| 1203 |
+
)
|
| 1204 |
p_migrate.set_defaults(func=cmd_migrate)
|
| 1205 |
|
| 1206 |
return p
|
|
@@ -330,7 +330,7 @@ class GuidedIO:
|
|
| 330 |
def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
|
| 331 |
io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
|
| 332 |
extract_fn: Optional[Callable[[Path, Path], None]] = None,
|
| 333 |
-
debloat: bool = True, now=None) -> dict:
|
| 334 |
"""The assembled guided flow: backup -> auto-wire each harness (instructions on
|
| 335 |
failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
|
| 336 |
report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
|
|
@@ -367,7 +367,7 @@ def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
|
|
| 367 |
if w.current_state().get("wired_with_sibyl"):
|
| 368 |
wire_report[name] = "already"
|
| 369 |
continue
|
| 370 |
-
outcome = w.wire()
|
| 371 |
wire_report[name] = outcome.status
|
| 372 |
if outcome.status not in ("wired", "already"):
|
| 373 |
io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
|
|
|
|
| 330 |
def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
|
| 331 |
io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
|
| 332 |
extract_fn: Optional[Callable[[Path, Path], None]] = None,
|
| 333 |
+
debloat: bool = True, force: bool = False, now=None) -> dict:
|
| 334 |
"""The assembled guided flow: backup -> auto-wire each harness (instructions on
|
| 335 |
failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
|
| 336 |
report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
|
|
|
|
| 367 |
if w.current_state().get("wired_with_sibyl"):
|
| 368 |
wire_report[name] = "already"
|
| 369 |
continue
|
| 370 |
+
outcome = w.wire(force=force)
|
| 371 |
wire_report[name] = outcome.status
|
| 372 |
if outcome.status not in ("wired", "already"):
|
| 373 |
io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression (bugflow 2026-06-05): `sibyl migrate --force` must reach the wirers.
|
| 2 |
+
|
| 3 |
+
Onboarding dead-end: when a detected harness already had a non-sibyl memory
|
| 4 |
+
provider, the wirer refused with "Use --force to overwrite." but
|
| 5 |
+
`run_guided_setup` called `wire()` with no `force`, and `sibyl migrate` had no
|
| 6 |
+
`--force` flag to pass. The flag now threads cli -> run_guided_setup(force=) ->
|
| 7 |
+
wire(force=force).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from sibyl_memory_cli import migrate as M
|
| 12 |
+
from sibyl_memory_cli.setup import WireOutcome
|
| 13 |
+
from sibyl_memory_client import MemoryClient
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class _RecordingWirer:
|
| 17 |
+
"""A fake harness wirer that records the `force` kwarg it was called with."""
|
| 18 |
+
|
| 19 |
+
name = "rec"
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.seen_force = None
|
| 23 |
+
|
| 24 |
+
def is_present(self):
|
| 25 |
+
return True
|
| 26 |
+
|
| 27 |
+
def current_state(self):
|
| 28 |
+
return {"wired_with_sibyl": False}
|
| 29 |
+
|
| 30 |
+
def wire(self, *, force: bool = False, dry_run: bool = False, prompt_fn=None):
|
| 31 |
+
self.seen_force = force
|
| 32 |
+
return WireOutcome(self.name, "wired", "ok")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _home_with_memory(tmp_path):
|
| 36 |
+
h = tmp_path / "home"
|
| 37 |
+
(h / "proj").mkdir(parents=True)
|
| 38 |
+
(h / "proj" / "CLAUDE.md").write_text("# memory\n- a fact worth keeping\n")
|
| 39 |
+
db = h / ".sibyl-memory" / "memory.db"
|
| 40 |
+
db.parent.mkdir(parents=True)
|
| 41 |
+
return h, db
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _fake_extract(_backup_dir, db_path):
|
| 45 |
+
MemoryClient.local(str(db_path), tenant_id="qa").set_entity("f", "a", {"v": 1})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_force_true_threads_to_wirer(tmp_path):
|
| 49 |
+
h, db = _home_with_memory(tmp_path)
|
| 50 |
+
rec = _RecordingWirer()
|
| 51 |
+
M.run_guided_setup(
|
| 52 |
+
home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
|
| 53 |
+
io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
|
| 54 |
+
extract_fn=_fake_extract, force=True,
|
| 55 |
+
)
|
| 56 |
+
assert rec.seen_force is True
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_force_defaults_false(tmp_path):
|
| 60 |
+
h, db = _home_with_memory(tmp_path)
|
| 61 |
+
rec = _RecordingWirer()
|
| 62 |
+
M.run_guided_setup(
|
| 63 |
+
home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
|
| 64 |
+
io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
|
| 65 |
+
extract_fn=_fake_extract,
|
| 66 |
+
)
|
| 67 |
+
assert rec.seen_force is False
|
|
@@ -4,6 +4,20 @@ All notable changes to `sibyl-memory-mcp` are recorded here. Format follows
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## [0.1.6] - 2026-06-04
|
| 8 |
|
| 9 |
### Added
|
|
|
|
| 4 |
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows
|
| 5 |
[SemVer](https://semver.org/).
|
| 6 |
|
| 7 |
+
## [0.1.7] - 2026-06-05
|
| 8 |
+
|
| 9 |
+
### Fixed
|
| 10 |
+
|
| 11 |
+
- **Tool errors now set the MCP `isError` flag (agent error-detection).**
|
| 12 |
+
`_err()` previously returned a plain dict, which FastMCP delivered as a
|
| 13 |
+
*successful* tool result (`isError: false`) with the error nested inside the
|
| 14 |
+
payload, so an agent keying off the protocol-level `isError` flag could not
|
| 15 |
+
detect the failure at all. `_err()` now raises `ToolError` carrying the same
|
| 16 |
+
structured payload encoded as JSON, so callers both (a) see `isError: true`
|
| 17 |
+
and (b) can still parse `error`/`code`/`recovery`/`upgrade_url` from the
|
| 18 |
+
message. No tool signatures change; only the error envelope is corrected.
|
| 19 |
+
Regression coverage: `tests/test_err_toolerror_2026_06_05.py`. (bugflow)
|
| 20 |
+
|
| 21 |
## [0.1.6] - 2026-06-04
|
| 22 |
|
| 23 |
### Added
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-mcp"
|
| 7 |
-
version = "0.1.
|
| 8 |
description = "MCP server for Sibyl Memory Plugin: wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP)."
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "sibyl-memory-mcp"
|
| 7 |
+
version = "0.1.7"
|
| 8 |
description = "MCP server for Sibyl Memory Plugin: wraps the local SQLite + FTS5 memory engine and exposes it to MCP-compatible agents (Claude Code, Codex, Cursor, Continue, anything that speaks MCP)."
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
@@ -35,9 +35,10 @@ import os
|
|
| 35 |
import re
|
| 36 |
import threading
|
| 37 |
from pathlib import Path
|
| 38 |
-
from typing import Any
|
| 39 |
|
| 40 |
from mcp.server.fastmcp import FastMCP
|
|
|
|
| 41 |
from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
|
| 42 |
from sibyl_memory_client.exceptions import (
|
| 43 |
CapExceededError,
|
|
@@ -166,8 +167,16 @@ def _build_client() -> MemoryClient:
|
|
| 166 |
# Error mapping
|
| 167 |
# ----------------------------------------------------------------------
|
| 168 |
|
| 169 |
-
def _err(e: Exception) ->
|
| 170 |
-
"""Map SDK exception
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
cls = type(e).__name__
|
| 172 |
payload = {"error": cls, "message": str(e)}
|
| 173 |
if isinstance(e, CapExceededError):
|
|
@@ -184,7 +193,7 @@ def _err(e: Exception) -> dict[str, Any]:
|
|
| 184 |
payload["code"] = "NOT_FOUND"
|
| 185 |
elif isinstance(e, ValidationError):
|
| 186 |
payload["code"] = "VALIDATION_ERROR"
|
| 187 |
-
|
| 188 |
|
| 189 |
|
| 190 |
def _coerce_body(body: Any) -> Any:
|
|
|
|
| 35 |
import re
|
| 36 |
import threading
|
| 37 |
from pathlib import Path
|
| 38 |
+
from typing import Any, NoReturn
|
| 39 |
|
| 40 |
from mcp.server.fastmcp import FastMCP
|
| 41 |
+
from mcp.server.fastmcp.exceptions import ToolError
|
| 42 |
from sibyl_memory_client import DEFAULT_TENANT, MemoryClient
|
| 43 |
from sibyl_memory_client.exceptions import (
|
| 44 |
CapExceededError,
|
|
|
|
| 167 |
# Error mapping
|
| 168 |
# ----------------------------------------------------------------------
|
| 169 |
|
| 170 |
+
def _err(e: Exception) -> NoReturn:
|
| 171 |
+
"""Map an SDK exception to a ToolError so the MCP envelope sets isError=true.
|
| 172 |
+
|
| 173 |
+
Previously this returned a plain dict, which FastMCP delivered as a
|
| 174 |
+
SUCCESSFUL tool result (isError=false) with the error nested inside the
|
| 175 |
+
payload. An agent keying off the protocol-level isError flag could not
|
| 176 |
+
detect the failure at all. We now raise ToolError carrying the same
|
| 177 |
+
structured payload encoded as JSON, so callers both (a) see isError=true
|
| 178 |
+
and (b) can still parse error/code/recovery/upgrade_url from the message.
|
| 179 |
+
"""
|
| 180 |
cls = type(e).__name__
|
| 181 |
payload = {"error": cls, "message": str(e)}
|
| 182 |
if isinstance(e, CapExceededError):
|
|
|
|
| 193 |
payload["code"] = "NOT_FOUND"
|
| 194 |
elif isinstance(e, ValidationError):
|
| 195 |
payload["code"] = "VALIDATION_ERROR"
|
| 196 |
+
raise ToolError(json.dumps(payload, ensure_ascii=False))
|
| 197 |
|
| 198 |
|
| 199 |
def _coerce_body(body: Any) -> Any:
|
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression (bugflow 2026-06-05): tool errors must set the MCP `isError` flag.
|
| 2 |
+
|
| 3 |
+
`_err()` used to return a plain dict, which FastMCP delivered as a *successful*
|
| 4 |
+
tool result (`isError: false`) with the error nested inside the payload — an
|
| 5 |
+
agent keying off the protocol-level `isError` flag could not detect the failure
|
| 6 |
+
at all. `_err()` now raises `ToolError` carrying the same structured payload as
|
| 7 |
+
JSON, so callers see `isError: true` AND can still parse error/code/recovery.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from mcp.server.fastmcp.exceptions import ToolError
|
| 15 |
+
|
| 16 |
+
import sibyl_memory_mcp.server as server
|
| 17 |
+
from sibyl_memory_client.exceptions import (
|
| 18 |
+
CapExceededError,
|
| 19 |
+
NotFoundError,
|
| 20 |
+
TierGateError,
|
| 21 |
+
ValidationError,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_err_raises_toolerror_not_returns_dict():
|
| 26 |
+
# The whole point of the fix: _err raises rather than returns.
|
| 27 |
+
with pytest.raises(ToolError):
|
| 28 |
+
server._err(NotFoundError("entity not found"))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.mark.parametrize(
|
| 32 |
+
"exc, code",
|
| 33 |
+
[
|
| 34 |
+
(CapExceededError("over the 2 MB free-tier cap", current_size=3_000_000, cap=2_000_000), "CAP_EXCEEDED"),
|
| 35 |
+
(TierGateError("paid feature", feature="self_learning"), "TIER_GATED"),
|
| 36 |
+
(NotFoundError("missing"), "NOT_FOUND"),
|
| 37 |
+
(ValidationError("bad body"), "VALIDATION_ERROR"),
|
| 38 |
+
],
|
| 39 |
+
)
|
| 40 |
+
def test_err_toolerror_preserves_structured_payload(exc, code):
|
| 41 |
+
with pytest.raises(ToolError) as ei:
|
| 42 |
+
server._err(exc)
|
| 43 |
+
payload = json.loads(str(ei.value))
|
| 44 |
+
assert payload["code"] == code
|
| 45 |
+
assert payload["error"] == type(exc).__name__
|
| 46 |
+
assert payload["message"] # non-empty human message survives
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_cap_exceeded_keeps_recovery_and_upgrade_url():
|
| 50 |
+
with pytest.raises(ToolError) as ei:
|
| 51 |
+
server._err(CapExceededError("cap", current_size=3_000_000, cap=2_000_000))
|
| 52 |
+
payload = json.loads(str(ei.value))
|
| 53 |
+
assert "Run `sibyl upgrade`" in payload["recovery"]
|
| 54 |
+
assert payload["upgrade_url"].startswith("https://")
|