feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree

The single/multi/hybrid 'deployment shape' was a misnomer: these keys only
affect the gateway (the one entrypoint supplying a runtime user ID), and the
three preset names stamped a lossy taxonomy onto three orthogonal knobs while
hiding which keys got written.

Replace it with an intent-led tree gated on gateway detection:
- _gateway_platforms() lazily inspects the gateway config (best-effort, no
  hard dependency); the step auto-skips when no platform is connected.
- 'who talks to this?' → just me / me+others (pooled?) / only others, deriving
  pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result.
- [e] drops to a raw-knob editor for power users.
- The single→multi orphan guard survives as a pooling steer.
This commit is contained in:
Erosika 2026-06-10 16:14:24 -04:00
parent bb5cb32838
commit d7dfeed6dc
2 changed files with 303 additions and 155 deletions

View file

@ -391,6 +391,100 @@ def _migrate_pin_key(block: dict) -> bool:
return True
def _gateway_platforms() -> list[str] | None:
"""Connected gateway platforms, or None if undetectable.
Identity mapping only affects gateway runtime users, so setup gates the
whole step on this. Best-effort and dependency-free: the memory plugin
must not hard-depend on the gateway package, so the import is lazy and
guarded (matching the idiom hermes_cli already uses for gateway refs).
"""
try:
from gateway.config import load_gateway_config
return [p.value for p in load_gateway_config().get_connected_platforms()]
except Exception:
return None
def _collect_operator_aliases(existing: dict, peer_target: str) -> dict:
"""Prompt for the operator's per-platform runtime IDs, aliasing each to
``peer_target``. Existing entries are preserved."""
aliases = dict(existing)
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 86701400"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
aliases[entered] = peer_target
return aliases
def _apply_runtime_prefix(
hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str
) -> None:
"""Write a host-level runtimePeerPrefix only when it diverges from an
inherited root value; otherwise let the root cascade stand."""
new_prefix = _prompt(label, default=current_prefix or "").strip()
if new_prefix and not (prefix_from_root and new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = new_prefix
def _echo_identity_mapping(hermes_host: dict) -> None:
"""Show the resulting keys so the operator can verify what was written."""
aliases = hermes_host.get("userPeerAliases")
prefix = hermes_host.get("runtimePeerPrefix")
print(" resolved →")
print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}")
print(f" userPeerAliases = {aliases if aliases else '{}'}")
print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}")
def _configure_raw_identity_mapping(
hermes_host: dict,
current_pin: bool,
current_aliases: dict,
current_prefix: str,
aliases_from_root: bool,
prefix_from_root: bool,
) -> None:
"""Power-user escape hatch: set the three resolver knobs directly."""
print("\n Raw identity-mapping keys (resolver tries them top-down):")
pin_in = _prompt(
"pinUserPeer — pin all gateway users to your peer? (true/false)",
default=str(bool(current_pin)).lower(),
).strip().lower()
pin = pin_in in {"true", "t", "yes", "y", "1"}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = pin
if pin:
return
aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):")
while True:
entry = _prompt(" alias", default="").strip()
if not entry:
break
if "=" in entry:
rid, peer = (p.strip() for p in entry.split("=", 1))
if rid and peer:
aliases[rid] = peer
if aliases:
hermes_host["userPeerAliases"] = aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"runtimePeerPrefix — namespace for unknown IDs (blank for none)",
)
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
suffix = f" [{default}]" if default else ""
sys.stdout.write(f" {label}{suffix}: ")
@ -560,18 +654,15 @@ def cmd_setup(args) -> None:
if new_workspace:
hermes_host["workspace"] = new_workspace
# --- 3b. Deployment shape ---
# Determines how runtime user identities (Telegram UIDs, Discord
# snowflakes, etc.) map to Honcho peers in gateway sessions. Three
# shapes cover the realistic deployments; each writes a different
# combination of pinPeerName / userPeerAliases / runtimePeerPrefix.
# See plugins/memory/honcho/README.md for the resolver ladder.
# --- 3b. Gateway identity mapping ---
# These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...),
# the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP
# sessions have no runtime ID and fall through to peerName, so the step is
# moot off-gateway — gate it behind detection.
#
# Detection must mirror the gateway resolver: root-level config and
# ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level)
# both affect effective routing, so reading host-only fields would
# mis-classify a profile that inherits its mapping from root or uses
# the newer canonical key.
# Detection mirrors the gateway resolver: root-level config and the
# canonical ``pinUserPeer`` both affect routing, so host-only reads would
# mis-classify a profile that inherits its mapping from root.
(
current_pin,
current_aliases,
@ -587,102 +678,109 @@ def cmd_setup(args) -> None:
else:
current_shape = "multi"
print("\n Deployment shape (how gateway users map to peers):")
print(" single -- all platforms route to your peer (recommended for personal use)")
print(" multi -- each platform user gets their own peer (multi-user bots)")
print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer")
print(" skip -- don't touch identity-mapping config")
new_shape = _prompt("Deployment shape", default=current_shape).strip().lower()
# Transitioning single → multi orphans the peerName pool for runtime users
# (their resolved peers go from peerName to runtime-derived IDs with empty
# history). Steer the operator toward hybrid so their own continuity is
# preserved via alias mappings.
if current_shape == "single" and new_shape == "multi":
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(
f"\n ⚠ Switching from single to multi will orphan memory accumulated\n"
f" under peer '{peer_target}'. Existing runtime users (Telegram,\n"
f" Discord, etc.) will resolve to fresh, empty peers."
)
print(" To keep your own continuity, choose 'hybrid' and alias your\n"
" runtime IDs back to peerName.")
confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower()
if confirm in {"hybrid", "h"}:
new_shape = "hybrid"
elif confirm not in {"yes", "y"}:
new_shape = "skip"
# Each shape branch scrubs every peer-mapping key before writing its own,
# so a stale alias/prefix/pin from an earlier run starts clean.
if new_shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = True
print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.")
elif new_shape == "multi":
# Preserve operator-curated, host-level aliases so multi → multi
# re-runs don't drop them. Root-sourced aliases are left to
# cascade naturally and are NOT copied down into the host.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
# Do NOT auto-write ``userPeerAliases: {}``: an empty host map
# would override any root-level ``userPeerAliases`` the operator
# set as a cross-host baseline, silently disabling those aliases.
# Absence is the right "no host opinion" signal.
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
# Only write a host-level prefix when the operator typed one that
# diverges from the inherited root value; otherwise let the root
# cascade continue unmodified.
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.")
elif new_shape == "hybrid":
# Hybrid encodes operator intent at the host level: collect existing
# entries (host or root) so the wizard never silently drops a known
# alias, then write the combined map. Materialising root entries
# into the host is the right move here — once the operator answers
# the alias prompts for a host, they're declaring "this host owns
# the mapping".
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
peer_target = hermes_host.get("peerName") or current_peer or "user"
print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.")
print(" Leave blank to skip a platform. Existing aliases are preserved.")
for platform_label, alias_hint in (
("Telegram UID", "e.g. 86701400"),
("Discord snowflake", "e.g. 491827364"),
("Slack user ID", "e.g. U04ABCDEF"),
("Matrix MXID", "e.g. @you:matrix.org"),
):
entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip()
if entered:
existing_aliases[entered] = peer_target
if existing_aliases:
hermes_host["userPeerAliases"] = existing_aliases
_prefix_default = current_prefix or ""
_new_prefix = _prompt(
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
default=_prefix_default,
).strip()
if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix):
hermes_host["runtimePeerPrefix"] = _new_prefix
print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.")
elif new_shape == "skip":
pass # leave config untouched
gw_platforms = _gateway_platforms()
if gw_platforms is None:
print("\n Gateway identity mapping routes platform users to memory peers.")
run_mapping = _prompt(
"Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)",
default="n",
).strip().lower() in {"y", "yes"}
elif not gw_platforms:
print("\n No gateway platforms connected — identity mapping only affects")
print(" gateway users, so this step doesn't apply here.")
run_mapping = _prompt(
"Configure gateway mapping anyway? (y/N)", default="n",
).strip().lower() in {"y", "yes"}
else:
print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.")
print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}")
run_mapping = True
if run_mapping:
peer_target = hermes_host.get("peerName") or current_peer or "user"
default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3")
print("\n How should gateway users map to memory peers?")
print(" [1] just me — everyone collapses to your peer")
print(" [2] me + other people — keep mine pooled, others separate")
print(" [3] only other people — everyone gets their own peer")
print(" [s] skip (leave untouched) [e] edit raw keys")
choice = _prompt("Choice", default=default_choice).strip().lower()
if choice in {"2", "me+others", "both"}:
pooled = _prompt(
" Keep my own memory pooled across platforms? (Y/n)", default="y",
).strip().lower()
shape = "hybrid" if pooled in {"y", "yes", ""} else "multi"
elif choice in {"1", "me", "just-me"}:
shape = "single"
elif choice in {"3", "others"}:
shape = "multi"
elif choice in {"e", "edit", "raw"}:
shape = "raw"
else:
shape = "skip"
# Un-pinning a currently-pinned profile without aliasing strands the
# pooled peerName history; steer the operator toward pooling instead.
if current_pin and shape == "multi":
print(
f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n"
f" '{peer_target}'. Existing gateway users resolve to fresh,\n"
f" empty peers."
)
confirm = _prompt(
" Pool my own memory instead (alias my IDs to peerName)? (Y/n)",
default="y",
).strip().lower()
if confirm in {"y", "yes", ""}:
shape = "hybrid"
# Each branch scrubs every peer-mapping key first so a stale alias,
# prefix, or pin from an earlier run starts clean.
if shape == "single":
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = True
print(f" All gateway users route to '{peer_target}'.")
_echo_identity_mapping(hermes_host)
elif shape == "multi":
# Preserve operator-curated host-level aliases across multi → multi
# re-runs. Root-sourced aliases cascade naturally and are NOT
# copied down — an empty host map would mask a root baseline.
prior_aliases = (
dict(current_aliases)
if isinstance(current_aliases, dict) and not aliases_from_root
else {}
)
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
if prior_aliases:
hermes_host["userPeerAliases"] = prior_aliases
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix (e.g. 'telegram_', blank for none)",
)
print(" Each gateway user → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "hybrid":
existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {}
_scrub_identity_mapping(hermes_host)
hermes_host["pinUserPeer"] = False
merged = _collect_operator_aliases(existing_aliases, peer_target)
if merged:
hermes_host["userPeerAliases"] = merged
_apply_runtime_prefix(
hermes_host, current_prefix, prefix_from_root,
"Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)",
)
print(f" Your runtime IDs → '{peer_target}', others → own peer.")
_echo_identity_mapping(hermes_host)
elif shape == "raw":
_configure_raw_identity_mapping(
hermes_host, current_pin, current_aliases, current_prefix,
aliases_from_root, prefix_from_root,
)
_echo_identity_mapping(hermes_host)
else: # skip
print(" Identity mapping left untouched.")
# --- 4. Observation mode ---
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")

View file

@ -323,19 +323,20 @@ class TestCloneHonchoForProfile:
class TestSetupWizardDeploymentShape:
"""The deployment-shape step writes pinPeerName / userPeerAliases /
runtimePeerPrefix based on the operator's chosen shape.
"""The gateway identity-mapping tree writes pinUserPeer / userPeerAliases /
runtimePeerPrefix based on the operator's intent.
Single-operator deployments collapse all platforms to peerName.
Multi-user gateways leave the resolver to route per-runtime.
Hybrid deployments alias the operator's own runtime IDs only.
Choice [1] (just me) collapses all platforms to peerName.
Choice [3] (only other people) leaves the resolver to route per-runtime.
Choice [2] (me + others, pooled) aliases the operator's own runtime IDs.
These tests script the interactive _prompt calls and assert the
resulting hermes_host block, so the wizard's deployment-shape
These tests mock gateway detection and script the interactive _prompt
calls, asserting the resulting hermes_host block so the tree's routing
semantics stay locked even as adjacent prompts are added.
"""
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None):
def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None,
gateway_platforms=("telegram",)):
import plugins.memory.honcho.cli as honcho_cli
cfg_path = tmp_path / "config.json"
@ -348,6 +349,10 @@ class TestSetupWizardDeploymentShape:
monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None)
# Gate detection is mocked so tests control whether the tree runs.
# None → undetectable; list (possibly empty) → connected platforms.
gw = None if gateway_platforms is None else list(gateway_platforms)
monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw)
# Bypass config.yaml + connection test side effects.
monkeypatch.setattr(
@ -393,14 +398,14 @@ class TestSetupWizardDeploymentShape:
honcho_cli.cmd_setup(SimpleNamespace())
return cfg["hosts"]["hermes"]
def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path):
def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"single", # deployment shape ← key answer
"1", # tree: just me ← key answer
# remaining prompts fall through to defaults
]
initial_cfg = {
@ -415,14 +420,14 @@ class TestSetupWizardDeploymentShape:
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape
"3", # tree: only other people
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
@ -433,14 +438,15 @@ class TestSetupWizardDeploymentShape:
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path):
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"hybrid", # deployment shape
"2", # tree: me + other people
"y", # keep my memory pooled? → hybrid
"86701400", # telegram uid
"491827364", # discord snowflake
"", # slack (skip)
@ -467,7 +473,7 @@ class TestSetupWizardDeploymentShape:
}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes", "skip",
"cloud", "", "eri", "hermetika", "hermes", "s",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is True
@ -475,10 +481,10 @@ class TestSetupWizardDeploymentShape:
assert host["userPeerAliases"] == {"keep": "me"}
assert host["runtimePeerPrefix"] == "keep_"
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
"""Flipping single → multi triggers a warning that auto-steers the
operator to ``hybrid`` (default), so their own runtime IDs keep
landing on peerName instead of orphaning the pinned-pool history.
def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path):
"""Choosing 'only other people' on a currently-pinned profile triggers
the orphan warning, which auto-steers to pooled (hybrid) so the
operator's own runtime IDs keep landing on peerName.
"""
initial_cfg = {
"apiKey": "***",
@ -490,8 +496,8 @@ class TestSetupWizardDeploymentShape:
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"3", # tree: only others — triggers the orphan guard
"y", # pool my own memory instead? → hybrid
"86701400", # telegram uid
"", # discord (skip)
"", # slack (skip)
@ -502,42 +508,40 @@ class TestSetupWizardDeploymentShape:
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"86701400": "eri"}
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
"""Operator can override the steer by answering ``yes`` and accept
the orphaning consequences. This is the explicit undo-the-pin path.
"""
def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path):
"""Operator can decline the steer ('n') and accept orphaning, ending
up with per-user peers (no aliases)."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # deployment shape — triggers the guard
"yes", # guard response: confirm multi
"3", # tree: only others — triggers the orphan guard
"n", # decline pooling, accept orphaning
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is False
# See test_multi_shape_leaves_pin_false_and_accepts_prefix.
assert "userPeerAliases" not in host
assert host["runtimePeerPrefix"] == "telegram_"
def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path):
"""Host-level ``pinUserPeer: true`` must classify as ``single``.
Pressing Enter at the shape prompt then preserves the pin instead
of falling through to ``multi`` and orphaning the user's memory
pool the bug the wizard regressed when ``pinUserPeer`` landed
as a higher-precedence alias.
Pressing Enter at the choice prompt then preserves the pin instead
of falling through to per-user routing and orphaning the user's
memory pool the bug the wizard regressed when ``pinUserPeer``
landed as a higher-precedence alias.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}},
}
# Exhaust the iterator before the shape prompt so the scripted
# mock falls through to the prompt's default (which is the
# wizard-detected shape). Scripting an explicit "" would NOT
# exercise that fallthrough — the mock returns it literally.
# Exhaust the iterator before the choice prompt so the scripted
# mock falls through to the prompt's default (the detected shape →
# choice "1"). Scripting an explicit "" would NOT exercise that
# fallthrough — the mock returns it literally.
answers = ["cloud", "", "eri", "hermetika", "hermes"]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
# Scrub-then-write normalises onto the canonical pinUserPeer.
@ -581,16 +585,16 @@ class TestSetupWizardDeploymentShape:
# operator edits live on the host block they're inspecting.
assert host["userPeerAliases"] == {"86701400": "eri"}
def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicit ``multi`` must leave the host ``userPeerAliases`` key
absent, preserving any root-level aliases as a cross-host baseline.
def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path):
"""Explicitly choosing 'only other people' must leave the host
``userPeerAliases`` key absent, preserving any root-level aliases as a
cross-host baseline.
Picking ``multi`` here is an active choice detection would have
defaulted to ``hybrid`` because root aliases exist so the
operator's intent is to drop the alias mapping for this host.
We honor that by writing ``pinUserPeer: false`` only, and rely
on the host's absence of ``userPeerAliases`` to inherit root.
That inheritance is intentional: a true wipe would require the
Picking [3] here is an active choice detection would have defaulted
to [2]/hybrid because root aliases exist so the operator's intent is
to drop the alias mapping for this host. We honor that by writing
``pinUserPeer: false`` only, relying on the host's absence of
``userPeerAliases`` to inherit root. A true wipe would require the
operator to delete the root key explicitly.
"""
initial_cfg = {
@ -600,14 +604,14 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # explicit multi override of detected hybrid
"3", # explicit per-user override of detected hybrid
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is False
assert "userPeerAliases" not in host
def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing ``single`` must overwrite a stale ``pinUserPeer: false``
def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path):
"""Choosing 'just me' must overwrite a stale ``pinUserPeer: false``
with ``pinUserPeer: true`` so the profile ends up genuinely pinned.
"""
initial_cfg = {
@ -619,11 +623,57 @@ class TestSetupWizardDeploymentShape:
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"single",
"1",
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinUserPeer"] is True
def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""With no gateway platforms connected, the tree is gated off; declining
the 'configure anyway?' prompt leaves identity mapping untouched."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=[],
)
assert "pinUserPeer" not in host
assert "userPeerAliases" not in host
assert "runtimePeerPrefix" not in host
def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path):
"""When the gateway package can't be inspected (None), the wizard asks
whether the gateway is running; 'no' skips the mapping step."""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"peerName": "eri"}},
}
answers = ["cloud", "", "eri", "hermetika", "hermes", "n"]
host = self._run_setup(
monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg,
gateway_platforms=None,
)
assert "pinUserPeer" not in host
def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path):
"""The [e] escape hatch lets a power user set pinUserPeer + an alias +
prefix directly, bypassing the intent tree."""
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"e", # tree: edit raw keys
"false", # pinUserPeer
"99887766=eri", # one alias pair
"", # finish aliases
"discord_", # runtimePeerPrefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers)
assert host["pinUserPeer"] is False
assert host["userPeerAliases"] == {"99887766": "eri"}
assert host["runtimePeerPrefix"] == "discord_"
class TestCloneCarriesPinUserPeer:
"""``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a