CLI yolo mode approval bypass not working correctly
NousResearch/hermes-agent · cluster 33925 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #33931. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #33931. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PRs #33931, #61715. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bug reports and fix attempts addressing the --yolo flag and /yolo chat command failing to bypass dangerous command approvals, due to environment variable timing issues relative to plugin discovery and missing session-level enable calls.
Open issues0
Open PRs0
Fixes on record
-
#33931 fix(cli): /yolo in chat must enable session bypass, not just set env var
files:
cli.py,tests/cli/test_cli_yolo_toggle.pyShow the diff
-
cli.py@@ -168,7 +168,7 @@ def realign_markdown_tables(*args, **kwargs): try_launch_chrome_debug, ) from hermes_cli.env_loader import load_hermes_dotenv -from utils import base_url_host_matches, is_truthy_value +from utils import base_url_host_matches _hermes_home = get_hermes_home() _project_env = Path(__file__).parent / '.env' @@ -3747,7 +3747,7 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: percent_label = f"{percent}%" if percent is not None else "--" duration_label = snapshot["duration"] - yolo_active = bool(os.getenv("HERMES_YOLO_MODE")) + yolo_active = self._is_session_yolo_active() if width < 52: text = f"⚕ {snapshot['model_short']} · {duration_label}" if yolo_active: @@ -3808,7 +3808,7 @@ def _get_status_bar_fragments(self): # line and produce duplicated status bar rows over long sessions. width = self._get_tui_terminal_width() duration_label = snapshot["duration"] - yolo_active = bool(os.getenv("HERMES_YOLO_MODE")) + yolo_active = self._is_session_yolo_active() if width < 52: frags = [ @@ -6907,6 +6907,7 @@ def _handle_branch_command(self, cmd_original: str) -> None: pass # Switch to the new session + self._transfer_session_yolo(self.session_id, new_session_id) self.session_id = new_session_id self.session_start = now self._pending_title = None @@ -9607,20 +9608,92 @@ def _toggle_verbose(self): } _cprint(labels.get(self.tool_progress_mode, "")) + def _transfer_session_yolo(self, old_session_id: str, new_session_id: str) -> None: + """Move YOLO bypass state from an old session key to a new one. + + Called whenever ``self.session_id`` is reassigned mid-run — ``/branch`` + forks into a new session, and auto-compression rotates the agent's + session id into a fresh continuation session. Without this transfer + the user's ``/yolo ON`` toggle would silently revert on the very next + turn (the same UX failure mode that motivated this entire fix), since + ``_session_yolo`` is keyed by session id. + + Mirrors ``tui_gateway/server.py`` (~line 1297-1305) which performs the + same transfer for the TUI's session-rename path. No-op when YOLO + wasn't enabled or when the ids match. + """ + if not old_session_id or not new_session_id or old_session_id == new_session_id: + return + try: + from tools.approval import ( + disable_session_yolo, + enable_session_yolo, + is_session_yolo_enabled, + ) + except Exception: + return + if is_session_yolo_enabled(old_session_id): + enable_session_yolo(new_session_id) + disable_session_yolo(old_session_id) + + def _is_session_yolo_active(self) -> bool: + """Whether YOLO bypass is currently enabled for this CLI session. + + Reads from ``tools.approval._session_yolo`` (the same set that + ``enable_session_yolo`` / ``disable_session_yolo`` write to) so the + status bar reflects the actual bypass state instead of a stale env + var. Also honors the process-start ``--yolo`` flag, which freezes + ``HERMES_YOLO_MODE`` into ``_YOLO_MODE_FROZEN`` before tool imports + happen. + """ + try: + from tools.approval import ( + _YOLO_MODE_FROZEN, + is_session_yolo_enabled, + ) + except Exception: + return False + if _YOLO_MODE_FROZEN: + return True + # Use ``getattr`` so test fixtures that build a CLI via ``__new__`` + # (skipping ``__init__``) don't trip an AttributeError here; the + # status-bar builders swallow exceptions silently but lose every + # field after the failure. + session_key = getattr(self, "session_id", None) or "default" + return is_session_yolo_enabled(session_key) + def _toggle_yolo(self): - """Toggle YOLO mode — skip all dangerous command approval prompts.""" - import os + """Toggle YOLO mode — skip all dangerous command approval prompts. + + Per-session toggle that mirrors the gateway and TUI ``/yolo`` handlers + (see ``gateway/run.py:_handle_yolo_command`` and + ``tui_gateway/server.py`` key=="yolo"). We deliberately do NOT mutate + ``HERMES_YOLO_MODE`` here — that env var is read once at module import + time into ``tools.approval._YOLO_MODE_FROZEN`` to keep prompt-injected + skills from flipping the bypass mid-session, so setting it after CLI + startup is a silent no-op. Routing through ``enable_session_yolo`` / + ``disable_session_yolo`` gives the same auditable, per-session bypass + the other surfaces have. ``run_conversation`` binds + ``self.session_id`` as the active approval session key via + ``set_current_session_key`` so the bypass takes effect on the very + next dangerous command in this run. + """ from hermes_cli.colors import Colors as _Colors + from tools.approval import ( + disable_session_yolo, + enable_session_yolo, + is_session_yolo_enabled, + ) - current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE")) - if current: - os.environ.pop("HERMES_YOLO_MODE", None) + session_key = self.session_id or "default" + if is_session_yolo_enabled(session_key): + disable_session_yolo(session_key) _cprint( f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}" " — dangerous commands will require approval." ) else: - os.environ["HERMES_YOLO_MODE"] = "1" + enable_session_yolo(session_key) _cprint( f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}" " — all commands auto-approved. Use with caution." @@ -11757,6 +11830,23 @@ def run_agent(): set_secret_capture_callback(self._secret_capture_callback) except Exception: pass + # Bind this turn's approval session key into the contextvar so + # ``tools.approval.is_current_session_yolo_enabled()`` resolves + # against the same key that ``/yolo`` toggles under (see + # ``_toggle_yolo`` → ``enable_session_yolo(self.session_id)``). + # Mirrors ``tui_gateway/server.py`` and ``gateway/run.py`` which + # bind the same contextvar before invoking the agent. + try: + from tools.approval import ( + reset_current_session_key, + set_current_session_key, + ) + _approval_session_token = [redacted:secret-assignment] + self.session_id or "default" + ) + except Exception: + reset_current_session_key = None # type: ignore[assignment] + _approval_session_token = None agent_message = _voice_prefix + message if _voice_prefix else message # Prepend pending model switch note so the model knows about the switch _msn = getattr(self, '_pending_model_switch_note', None) @@ -11798,6 +11888,15 @@ def run_agent(): set_secret_capture_callback(None) except Exception: pass + # Release the per-turn approval session key. ``_session_yolo`` + # state itself is preserved across turns (so /yolo persists + # for the whole CLI run); we just unbind the contextvar so a + # reused thread doesn't see stale identity on its next run. + if _approval_session_token is not None and reset_current_session_key is not None: + try: + reset_current_session_key(_approval_session_token) + except Exception: + pass # Start agent in background thread (daemon so it cannot keep the # process alive when the user closes the terminal tab — SIGHUP @@ -11928,6 +12027,7 @@ def run_agent(): and getattr(self.agent, "session_id", None) and self.agent.session_id != self.session_id ): + self._transfer_session_yolo(self.session_id, self.agent.session_id) self.session_id = self.agent.session_id self._pending_title = None -
tests/cli/test_cli_yolo_toggle.py@@ -0,0 +1,244 @@ +"""Regression tests for the CLI ``/yolo`` in-chat toggle. + +Pre-fix bug (issue #33925): ``cli.HermesCLI._toggle_yolo`` mutated only +``os.environ["HERMES_YOLO_MODE"]``. That env var is captured once at +module-import time into ``tools.approval._YOLO_MODE_FROZEN`` (security +hardening: stops prompt-injected skills from flipping the bypass mid-run), +so the post-startup toggle was a silent no-op. ``/yolo`` advertised "YOLO ON" +in the status bar while every dangerous command still hit the approval +prompt. Only ``hermes --yolo`` (process-start env), ``HERMES_YOLO_MODE=1``, +and ``hermes config set approvals.mode off`` actually bypassed. + +The fix routes the CLI toggle through ``enable_session_yolo`` / +``disable_session_yolo`` (matching the gateway and TUI ``/yolo`` paths) and +binds ``self.session_id`` as the active approval session key around each +``run_conversation`` call so ``is_current_session_yolo_enabled()`` resolves +against the same key the toggle writes under. + +We test ``_toggle_yolo`` and ``_is_session_yolo_active`` as unbound methods +against a minimal stand-in object that exposes only the attribute they +read (``session_id``). This avoids the heavy ``HermesCLI`` construction +path used in ``test_cli_init.py``, which is incompatible with this test +file's path layout — ``HermesCLI.__init__`` imports a lot of optional +state we don't need here. +""" + +import os +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +import tools.approval as approval_module +from cli import HermesCLI + + +SESSION_KEY = "test-cli-yolo-session" + + [redacted:email](autouse=True) +def _clear_approval_state(monkeypatch): + """Clear the YOLO bypass + env var around every test so cases are independent.""" + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + approval_module.clear_session(SESSION_KEY) + approval_module.clear_session("default") + yield + approval_module.clear_session(SESSION_KEY) + approval_module.clear_session("default") + + +def _make_stand_in(session_id: str = SESSION_KEY) -> SimpleNamespace: + """Minimal stand-in exposing only ``session_id``. + + ``_toggle_yolo`` and ``_is_session_yolo_active`` are both pure methods + that only read ``self.session_id`` — no other CLI state is touched. + Calling them as unbound functions against this stand-in is equivalent + to invoking them on a fully-constructed ``HermesCLI`` for the + behaviour under test, and avoids the brittle prompt_toolkit / config + stubbing required to instantiate ``HermesCLI`` from this test file. + """ + return SimpleNamespace(session_id=session_id) + + +class TestToggleYoloIsSessionScoped: + """The CLI /yolo handler must mutate the session-yolo set, not the env var. + + The env var path is dead-on-arrival because ``_YOLO_MODE_FROZEN`` is + captured once at module import, long before the CLI's ``/yolo`` command + can run. + """ + + def test_toggle_yolo_enables_session_bypass(self): + stand_in = _make_stand_in() + + assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False + + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + + assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True + + def test_toggle_yolo_disables_session_bypass_on_second_call(self): + stand_in = _make_stand_in() + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) # ON + assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True + HermesCLI._toggle_yolo(stand_in) # OFF + assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False + + def test_toggle_yolo_does_not_mutate_env_var(self): + """Toggling /yolo must not write ``HERMES_YOLO_MODE`` — that path is + frozen at import time and would mislead anyone reading the env later + (subprocesses, status bars wired to the env, the relaunch flag list).""" + stand_in = _make_stand_in() + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + + assert os.environ.get("HERMES_YOLO_MODE") is None + + def test_toggle_yolo_falls_back_to_default_when_session_id_missing(self): + """An edge case during CLI bootstrap: a ``/yolo`` triggered before the + session id is set should not blow up, and should land under the + ``default`` session key so the bypass still takes effect for any code + that resolves against the default key.""" + stand_in = _make_stand_in(session_id="") + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + + assert approval_module.is_session_yolo_enabled("default") is True + + def test_two_independent_sessions_are_isolated(self): + """``/yolo`` toggled in one session must not bypass approvals in + another session — mirrors the gateway-side invariant.""" + cli_a = _make_stand_in(session_id="session-yolo-a") + cli_b = _make_stand_in(session_id="session-yolo-b") + + try: + with patch("cli._cprint"): + HermesCLI._toggle_yolo(cli_a) + + assert approval_module.is_session_yolo_enabled("session-yolo-a") is True + assert approval_module.is_session_yolo_enabled("session-yolo-b") is False + finally: + approval_module.clear_session("session-yolo-a") + approval_module.clear_session("session-yolo-b") + + +class TestIsSessionYoloActiveHelper: + """The status-bar helper must read the live session-yolo state, not the + env var (which is the bug class this PR fixes).""" + + def test_helper_reflects_toggle(self): + stand_in = _make_stand_in() + + assert HermesCLI._is_session_yolo_active(stand_in) is False + + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + + assert HermesCLI._is_session_yolo_active(stand_in) is True + + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) + + assert HermesCLI._is_session_yolo_active(stand_in) is False + + def test_helper_honors_frozen_yolo_mode(self): + """``hermes --yolo`` sets ``HERMES_YOLO_MODE`` before tool imports, so + ``_YOLO_MODE_FROZEN`` ends up True. The status bar should still + reflect YOLO on in that case even when the session toggle is off.""" + stand_in = _make_stand_in() + + with patch.object(approval_module, "_YOLO_MODE_FROZEN", True): + assert HermesCLI._is_session_yolo_active(stand_in) is True + + +class TestToggleYoloEndToEnd: + """End-to-end: a dangerous command must auto-approve through the same + ``check_all_command_guards`` path the terminal tool uses.""" + + def test_toggle_yolo_bypasses_dangerous_command_check(self): + stand_in = _make_stand_in() + + token = [redacted:secret-assignment] + try: + with patch("cli._cprint"): + HermesCLI._toggle_yolo(stand_in) # YOLO ON + + result = approval_module.check_all_command_guards( + "rm -rf /tmp/scratch-xyzzy", "local", + ) + assert result["approved"] is True, ( + f"YOLO toggle should auto-approve dangerous commands, got: {result}" + ) + finally: + approval_module.reset_current_session_key(token) + + +class TestIsSessionYoloActiveAttrSafety: + """The status-bar helper runs against partially-constructed CLI fixtures + (tests use ``HermesCLI.__new__(HermesCLI)`` to skip ``__init__``). It must + not raise ``AttributeError`` when ``session_id`` is absent — the + status-bar builders swallow exceptions silently and lose every field + after the failure, producing a regression that's hard to track back to + the helper.""" + + def test_helper_survives_missing_session_id_attr(self): + # SimpleNamespace WITHOUT session_id mimics __new__-built fixtures. + from types import SimpleNamespace + no_attr = SimpleNamespace() + # Must return False, not raise. + assert HermesCLI._is_session_yolo_active(no_attr) is False + + +class TestSessionRotationTransfersYolo: + """When the CLI's ``session_id`` rotates mid-run (``/branch``, auto + compression continuation), YOLO state keyed under the old id must move + to the new id. Otherwise the user's ``/yolo ON`` silently reverts on + the next turn — the same UX failure mode this PR set out to fix. + Mirrors ``tui_gateway/server.py`` ~line 1297-1305.""" + + def test_transfer_moves_yolo_to_new_session(self): + stand_in = _make_stand_in(session_id="old-id") + try: + approval_module.enable_session_yolo("old-id") + assert approval_module.is_session_yolo_enabled("old-id") is True + + HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id") + + assert approval_module.is_session_yolo_enabled("new-id") is True + assert approval_module.is_session_yolo_enabled("old-id") is False + finally: + approval_module.clear_session("old-id") + approval_module.clear_session("new-id") + + def test_transfer_is_noop_when_yolo_was_off(self): + stand_in = _make_stand_in(session_id="old-id") + try: + HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id") + assert approval_module.is_session_yolo_enabled("new-id") is False + assert approval_module.is_session_yolo_enabled("old-id") is False + finally: + approval_module.clear_session("old-id") + approval_module.clear_session("new-id") + + def test_transfer_is_noop_when_ids_match(self): + stand_in = _make_stand_in(session_id="same-id") + try: + approval_module.enable_session_yolo("same-id") + HermesCLI._transfer_session_yolo(stand_in, "same-id", "same-id") + # Must NOT have been disabled — same-id == same-id is a no-op, + # not a "disable then re-enable" round-trip. + assert approval_module.is_session_yolo_enabled("same-id") is True + finally: + approval_module.clear_session("same-id") + + def test_transfer_handles_empty_inputs_safely(self): + stand_in = _make_stand_in(session_id="x") + # Both directions of empty input should be safe no-ops; nothing + # to transfer from "" / to "". + HermesCLI._transfer_session_yolo(stand_in, "", "new") + HermesCLI._transfer_session_yolo(stand_in, "old", "") + # Neither key should have been touched. + assert approval_module.is_session_yolo_enabled("new") is False + assert approval_module.is_session_yolo_enabled("old") is False
-
-
#61715 fix(cli): set HERMES_YOLO_MODE before plugin discovery freezes the approval bypass (#60328)
files:
hermes_cli/main.py,scripts/release.py,tests/hermes_cli/test_yolo_startup_order.pyShow the diff
-
hermes_cli/main.py@@ -2358,7 +2358,11 @@ def cmd_chat(args): except Exception: pass - # --yolo: bypass all dangerous command approvals + # --yolo: bypass all dangerous command approvals. + # Also set in main() before _prepare_agent_startup() — that is the + # authoritative site because it runs before tool imports freeze + # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers + # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" @@ -12410,6 +12414,15 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY + # plugin/tool discovery below imports tools.approval, which freezes + # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s + # dispatch path also sets this earlier, but _prepare_agent_startup() is + # reachable from other launchers too (e.g. the Termux fast-CLI path), + # so the guarantee lives here where the import is actually triggered + # (#60328). + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" _apply_safe_mode(args) _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) @@ -14627,6 +14640,15 @@ def _export_one(session_id: str): cmd_version(args) return + # --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to + # _prepare_agent_startup() below triggers discover_plugins() → tool + # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module + # import time (PR #7994, security hardening against prompt-injection). + # If the env var is set only later (e.g. inside cmd_chat), the frozen + # value is already False and --yolo silently does nothing. + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + # Discover Python plugins and register shell hooks once, before any # command that can fire lifecycle hooks. Both are idempotent; gated # so introspection/management commands (hermes hooks list, cron -
scripts/release.py@@ -299,6 +299,7 @@ "[redacted:email]": "synapsesx", "[redacted:email]": "itsflownium", "[redacted:email]": "dirtyren", + "[redacted:email]": "bytesnail", # PR #60360 salvage (--yolo startup ordering; #60328) "[redacted:email]": "nullptr0807", # PR #60956 salvage (gateway hygiene in-place compaction; #60947) "[redacted:email]": "embwl0x", # PR #60810 salvage (channel directory offload) "[redacted:email]": "doncazper", -
tests/hermes_cli/test_yolo_startup_order.py@@ -0,0 +1,77 @@ +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in +main() before _prepare_agent_startup() triggers tool imports. + +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct +by design (PR #7994). The bug was that main() set the env var inside +cmd_chat(), which runs *after* _prepare_agent_startup() has already +imported tools.approval and frozen the constant to False. + +These tests verify the ordering in main() itself: the env var must +already be set at the moment _prepare_agent_startup() is called. +If someone moves the assignment back into cmd_chat(), these tests +fail — catching the exact #60328 regression. +""" + +import os +import sys + + +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv): + """Run main() with *argv*, capturing HERMES_YOLO_MODE at the + moment _prepare_agent_startup is called. + + Returns the captured env var value (or None if unset). + """ + yolo_at_startup = {} + + def spy_prepare_startup(args): + yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE") + + monkeypatch.setattr( + "hermes_cli.main._prepare_agent_startup", spy_prepare_startup + ) + # Stub cmd_chat so main() returns cleanly without entering chat. + monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(sys, "argv", argv) + + from hermes_cli.main import main as cli_main + + cli_main() + + return yolo_at_startup.get("value") + + +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes --yolo must set HERMES_YOLO_MODE before + _prepare_agent_startup imports tools.approval.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with --yolo. This is the #60328 regression: " + "the env var is set too late (inside cmd_chat, after tool imports)." + ) + + +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes chat --yolo must also set HERMES_YOLO_MODE before + _prepare_agent_startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "chat", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with 'chat --yolo'." + ) + + +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch): + """Without --yolo, HERMES_YOLO_MODE must not be set at startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes"] + ) + assert result is None, ( + "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo." + )
-
4 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 1 · other 1
-
#34032 fix(cli): use session-scoped yolo bypass in /yolo command · done elsewhere →
5cbc3fb,#33931 -
#60349 fix(cli): set HERMES_YOLO_MODE before _prepare_agent_startup() imports (#60328) · other →
#60360,#61715 -
#60360 fix(cli): set HERMES_YOLO_MODE before plugin discovery at startup · merged via another change →
#61715,#60328 -
#60963 fix(cli): set HERMES_YOLO_MODE before plugin discovery · done elsewhere →
#61715,#60360
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.23: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab