Repository: marimo-team/marimo
Pull request #4301: improvement: start of secrets handling in the kernel
Author: login: mscolnick
Created: 2025-03-28T19:35:42Z | Merged: 2025-03-29T21:10:19Z

This is just the BE implementation for reading secrets (currently just from environment variables).

Will add the frontend UI in another PR


Diff:
diff --git a/.github/workflows/test_be.yaml b/.github/workflows/test_be.yaml
index cdeffd004ea..b89071f000a 100644
--- a/.github/workflows/test_be.yaml
+++ b/.github/workflows/test_be.yaml
@@ -131,7 +131,7 @@ jobs:
     if: ${{ needs.changes.outputs.backend == 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' }}
     name: Test coverage
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 25
     steps:
       - name: 🛑 Cancel Previous Runs
         uses: styfle/cancel-workflow-action@0.12.1
diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py
index b5a511e5a0d..85803e5451b 100644
--- a/marimo/_cli/development/commands.py
+++ b/marimo/_cli/development/commands.py
@@ -29,6 +29,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
     import marimo._server.models.home as home
     import marimo._server.models.models as models
     import marimo._server.models.packages as packages
+    import marimo._server.models.secrets as secrets
     import marimo._snippets.snippets as snippets
     from marimo import __version__
     from marimo._ast.cell import CellConfig, RuntimeStateType
@@ -71,6 +72,10 @@ def _generate_server_api_schema() -> dict[str, Any]:
         errors.Error,
         # Outputs
         CellOutput,
+        # Secrets
+        secrets.ListSecretKeysResponse,
+        secrets.CreateSecretRequest,
+        secrets.DeleteSecretRequest,
         # Data
         data.DataTableColumn,
         data.DataTable,
diff --git a/marimo/_dependencies/dependencies.py b/marimo/_dependencies/dependencies.py
index f994f4e3309..51336f973f1 100644
--- a/marimo/_dependencies/dependencies.py
+++ b/marimo/_dependencies/dependencies.py
@@ -165,6 +165,7 @@ class DependencyManager:
     pandas = Dependency("pandas")
     polars = Dependency("polars")
     ibis = Dependency("ibis")
+    dotenv = Dependency("dotenv")
     numpy = Dependency("numpy")
     altair = Dependency("altair", min_version="5.3.0", max_version="6.0.0")
     duckdb = Dependency("duckdb")
diff --git a/marimo/_messaging/ops.py b/marimo/_messaging/ops.py
index f26486e8e6b..e838947c2e7 100644
--- a/marimo/_messaging/ops.py
+++ b/marimo/_messaging/ops.py
@@ -52,6 +52,7 @@
 from marimo._runtime.context.types import ContextNotInitializedError
 from marimo._runtime.context.utils import get_mode
 from marimo._runtime.layout.layout import LayoutConfig
+from marimo._secrets.models import SecretKeysWithProvider
 from marimo._types.ids import CellId_t, RequestId
 from marimo._utils.platform import is_pyodide, is_windows
 
@@ -681,6 +682,12 @@ class UpdateCellCodes(Op):
     code_is_stale: bool
 
 
+@dataclass
+class SecretKeysResult(Op):
+    name: ClassVar[str] = "secret-keys-result"
+    secrets: list[SecretKeysWithProvider]
+
+
 @dataclass
 class UpdateCellIdsRequest(Op):
     """
diff --git a/marimo/_runtime/requests.py b/marimo/_runtime/requests.py
index 1fb81d44cb2..a91a99de6b3 100644
--- a/marimo/_runtime/requests.py
+++ b/marimo/_runtime/requests.py
@@ -131,7 +131,8 @@ def __repr__(self) -> str:
 
 
 @dataclass
-class ExecuteStaleRequest: ...
+class ExecuteStaleRequest:
+    request: Optional[HTTPRequest] = None
 
 
 @dataclass
@@ -327,6 +328,11 @@ class PreviewSQLTableListRequest:
     schema: str
 
 
+@dataclass
+class ListSecretKeysRequest:
+    pass
+
+
 ControlRequest = Union[
     ExecuteMultipleRequest,
     ExecuteScratchpadRequest,
@@ -343,4 +349,5 @@ class PreviewSQLTableListRequest:
     PreviewDatasetColumnRequest,
     PreviewSQLTableRequest,
     PreviewSQLTableListRequest,
+    ListSecretKeysRequest,
 ]
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index d17584793a8..54ebc8c7b9f 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -15,6 +15,7 @@
 import time
 import traceback
 from copy import copy, deepcopy
+from functools import cached_property
 from multiprocessing import connection
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Optional, cast
@@ -54,6 +55,7 @@
     MissingPackageAlert,
     PackageStatusType,
     RemoveUIElements,
+    SecretKeysResult,
     SQLTableListPreview,
     SQLTablePreview,
     VariableDeclaration,
@@ -114,6 +116,7 @@
     ExecutionRequest,
     FunctionCallRequest,
     InstallMissingPackagesRequest,
+    ListSecretKeysRequest,
     PreviewDatasetColumnRequest,
     PreviewSQLTableListRequest,
     PreviewSQLTableRequest,
@@ -141,6 +144,10 @@
 )
 from marimo._runtime.validate_graph import check_for_errors
 from marimo._runtime.win32_interrupt_handler import Win32InterruptHandler
+from marimo._secrets.load_dotenv import (
+    load_dotenv_with_fallback,
+)
+from marimo._secrets.secrets import get_secret_keys
 from marimo._server.model import SessionMode
 from marimo._server.types import QueueType
 from marimo._sql.engines.types import SQLEngine
@@ -154,7 +161,7 @@
 
 if TYPE_CHECKING:
     import queue
-    from collections.abc import Iterator, Sequence
+    from collections.abc import Awaitable, Iterator, Sequence
     from types import ModuleType
 
     from marimo._plugins.ui._core.ui_element import UIElement
@@ -473,6 +480,11 @@ def __init__(
         # timestamp, to save the user from having to spam the interrupt button
         self.last_interrupt_timestamp: Optional[float] = None
 
+        # Callbacks
+        self.secrets_callbacks = SecretsCallbacks(self)
+        self.datasets_callbacks = DatasetCallbacks(self)
+        self.packages_callbacks = PackagesCallbacks(self)
+
         # Apply pythonpath from config at initialization
         pythonpath = user_config["runtime"].get("pythonpath")
         if pythonpath:
@@ -558,7 +570,6 @@ def __init__(
         self.module_registry = ModuleRegistry(
             self.graph, excluded_modules=set()
         )
-        self.package_manager: PackageManager | None = None
         self.module_reloader: ModuleReloader | None = None
         self.module_watcher: ModuleWatcher | None = None
 
@@ -615,34 +626,13 @@ def lazy(self) -> bool:
     def _execute_stale_cells_callback(self) -> None:
         return self.enqueue_control_request(ExecuteStaleRequest())
 
-    def _execute_install_missing_packages_callback(
-        self, package_manager: str, packages: list[str]
-    ) -> None:
-        version = {pkg: "" for pkg in packages}
-        return self.enqueue_control_request(
-            InstallMissingPackagesRequest(
-                manager=package_manager, versions=version
-            )
-        )
-
     def _update_runtime_from_user_config(self, config: MarimoConfig) -> None:
         package_manager = config["package_management"]["manager"]
         autoreload_mode = config["runtime"]["auto_reload"]
         self.reactive_execution_mode = config["runtime"]["on_cell_change"]
         self.user_config = config
 
-        if (
-            self.package_manager is None
-            or package_manager != self.package_manager.name
-        ):
-            self.package_manager = create_package_manager(package_manager)
-
-            if self._should_update_script_metadata():
-                # All marimo notebooks depend on the marimo package; if the
-                # notebook already has marimo as a dependency, or an optional
-                # dependency group with marimo, such as marimo[sql], this is a
-                # NOOP.
-                self._update_script_metadata(["marimo"])
+        self.packages_callbacks.update_package_manager(package_manager)
 
         if (
             (autoreload_mode == "lazy" or autoreload_mode == "autorun")
@@ -898,7 +888,7 @@ def _maybe_register_cell(
             # packages used by a notebook; that would have the benefit of
             # discovering transitive dependencies, ie if a notebook used a
             # local module that in turn used packages available on PyPI.
-            if self._should_update_script_metadata():
+            if self.packages_callbacks.should_update_script_metadata():
                 cell = self.graph.cells.get(cell_id, None)
                 if cell:
                     prev_imports: set[Name] = (
@@ -907,7 +897,7 @@ def _maybe_register_cell(
                         else set()
                     )
                     to_add = cell.imported_namespaces - prev_imports
-                    self._update_script_metadata(
+                    self.packages_callbacks.update_script_metadata(
                         import_namespaces_to_add=list(to_add)
                     )
 
@@ -1002,25 +992,10 @@ def _invalidate_cell_state(
         missing_modules_after_deletion = (
             missing_modules_before_deletion & self.module_registry.modules()
         )
-        if (
-            self.package_manager is not None
-            and missing_modules_after_deletion
-            != missing_modules_before_deletion
-        ):
-            packages = list(
-                sorted(
-                    pkg
-                    for mod in missing_modules_after_deletion
-                    if not self.package_manager.attempted_to_install(
-                        pkg := self.package_manager.module_to_package(mod)
-                    )
-                )
+        if missing_modules_after_deletion != missing_modules_before_deletion:
+            self.packages_callbacks.send_missing_packages_alert(
+                missing_modules_after_deletion
             )
-            # Deleting a cell can make the set of missing packages smaller
-            MissingPackageAlert(
-                packages=packages,
-                isolated=is_python_isolated(),
-            ).broadcast()
 
         cell.set_output(None)
         get_context().cell_lifecycle_registry.dispose(
@@ -1063,10 +1038,8 @@ def _delete_cell(self, cell_id: CellId_t) -> set[CellId_t]:
         del self.cell_metadata[cell_id]
         cell = self.graph.cells[cell_id]
         cell.import_workspace.imported_defs = set()
-        if self._should_update_script_metadata():
-            self._update_script_metadata(
-                import_namespaces_to_add=[],
-            )
+
+        # Note: we don't remove packages from the inline script-metadata.
 
         return self._deactivate_cell(cell_id)
 
@@ -1329,67 +1302,6 @@ async def _if_autorun_then_run_cells(
             # modules are marked as stale
             self.graph.set_stale(cell_ids, prune_imports=True)
 
-    def _broadcast_missing_packages(self, runner: cell_runner.Runner) -> None:
-        module_not_found_errors = [
-            e
-            for e in runner.exceptions.values()
-            if isinstance(e, (ModuleNotFoundError, ManyModulesNotFoundError))
-        ]
-
-        if len(module_not_found_errors) == 0:
-            return
-
-        if self.package_manager is None:
-            return
-
-        missing_modules: set[str] = set()
-        missing_packages: set[str] = set()
-
-        # Populate missing_modules and missing_packages
-        # from the errors
-        for e in module_not_found_errors:
-            if isinstance(e, ManyModulesNotFoundError):
-                # filter out packages that we already attempted to install
-                # to prevent an infinite loop
-                missing_packages.update(
-                    {
-                        package
-                        for package in e.package_names
-                        if not self.package_manager.attempted_to_install(
-                            package
-                        )
-                    }
-                )
-            elif e.name is not None:
-                missing_modules.add(e.name)
-
-        # Grab missing modules from module registry and from module not found errors
-        missing_modules = (
-            self.module_registry.missing_modules() | missing_modules
-        )
-
-        # Convert modules to packages
-        for mod in missing_modules:
-            pkg = self.package_manager.module_to_package(mod)
-            # filter out packages that we already attempted to install
-            # to prevent an infinite loop
-            if not self.package_manager.attempted_to_install(pkg):
-                missing_packages.add(pkg)
-
-        if not missing_packages:
-            return
-
-        packages = list(sorted(missing_packages))
-        if self.package_manager.should_auto_install():
-            self._execute_install_missing_packages_callback(
-                self.package_manager.name, packages
-            )
-        else:
-            MissingPackageAlert(
-                packages=packages,
-                isolated=is_python_isolated(),
-            ).broadcast()
-
     def _propagate_kernel_errors(
         self,
         runner: cell_runner.Runner,
@@ -1441,7 +1353,7 @@ def note_time_of_interruption(
             on_finish_hooks=(
                 self._on_finish_hooks
                 + [
-                    self._broadcast_missing_packages,
+                    self.packages_callbacks.missing_packages_hook,
                     self._propagate_kernel_errors,
                 ]
             ),
@@ -1597,7 +1509,7 @@ async def _run_with_uninstantiated_requests(
     async def rename_file(self, filename: str) -> None:
         self.globals["__file__"] = filename
         self.app_metadata.filename = filename
-        roots = set()
+        roots: set[CellId_t] = set()
         for cell in self.graph.cells.values():
             if "__file__" in cell.refs:
                 roots.add(cell.cell_id)
@@ -1638,7 +1550,8 @@ async def run_scratchpad(self, code: str) -> None:
             pre_execution_hooks=self._pre_execution_hooks,
             post_execution_hooks=self._post_execution_hooks,
             on_finish_hooks=(
-                self._on_finish_hooks + [self._broadcast_missing_packages]
+                self._on_finish_hooks
+                + [self.packages_callbacks.missing_packages_hook]
             ),
         )
 
@@ -2015,117 +1928,135 @@ async def instantiate(self, request: CreationRequest) -> None:
                 CellOp.broadcast_stale(cell_id=cid, stale=True)
 
     def load_dotenv(self) -> None:
-        if not DependencyManager.dotenv.has():
+        dotenvs = self.user_config["runtime"].get("dotenv", [])
+        if not isinstance(dotenvs, list):
+            LOGGER.warning("dotenv configuration is not a list")
             return
 
-        from dotenv import load_dotenv  # type: ignore[import-not-found]
-
-        for env in self.user_config["runtime"]["dotenv"]:
+        for env in dotenvs:
             if Path(env).exists():
                 try:
-                    load_dotenv(env)
+                    load_dotenv_with_fallback(env)
                 except Exception as e:
                     LOGGER.error(
                         "Failed to load dotenv file %s", env, exc_info=e
                     )
 
-    async def install_missing_packages(
-        self, request: InstallMissingPackagesRequest
-    ) -> None:
-        """Attempts to install packages for modules that cannot be imported
+    @cached_property
+    def request_handler(self) -> RequestHandler:
+        handler = RequestHandler()
 
-        Runs cells affected by successful installation.
-        """
-        assert self.package_manager is not None
-        if request.manager != self.package_manager.name:
-            # Swap out the package manager
-            self.package_manager = create_package_manager(request.manager)
+        async def handle_instantiate(request: CreationRequest) -> None:
+            with http_request_context(request.request):
+                await self.instantiate(request)
+            CompletedRun().broadcast()
 
-        if not self.package_manager.is_manager_installed():
-            self.package_manager.alert_not_installed()
-            return
+        async def handle_execute_multiple(
+            request: ExecuteMultipleRequest,
+        ) -> None:
+            with http_request_context(request.request):
+                await self.run(request.execution_requests)
+            CompletedRun().broadcast()
 
-        missing_packages_set = set(request.versions.keys())
-        # Append all other missing packages from the notebook; the missing
-        # package request only contains the packages from the cell the user
-        # executed.
-        missing_packages_set.update(
-            [
-                self.package_manager.module_to_package(module)
-                for module in self.module_registry.missing_modules()
-            ]
-        )
-        missing_packages = list(sorted(missing_packages_set))
+        async def handle_execute_scratchpad(
+            request: ExecuteScratchpadRequest,
+        ) -> None:
+            with http_request_context(request.request):
+                await self.run_scratchpad(request.code)
+            CompletedRun().broadcast()
 
-        # Frontend shows package names, not module names
-        package_statuses: PackageStatusType = {
-            pkg: "queued" for pkg in missing_packages
-        }
-        InstallingPackageAlert(packages=package_statuses).broadcast()
+        async def handle_execute_stale(request: ExecuteStaleRequest) -> None:
+            with http_request_context(request.request):
+                await self.run_stale_cells()
+            CompletedRun().broadcast()
 
-        for pkg in missing_packages:
-            if self.package_manager.attempted_to_install(package=pkg):
-                # Already attempted an installation; it must have failed.
-                # Skip the installation.
-                continue
-            package_statuses[pkg] = "installing"
-            InstallingPackageAlert(packages=package_statuses).broadcast()
-            version = request.versions.get(pkg)
-            if await self.package_manager.install(pkg, version=version):
-                package_statuses[pkg] = "installed"
-                InstallingPackageAlert(packages=package_statuses).broadcast()
-            else:
-                package_statuses[pkg] = "failed"
-                mod = self.package_manager.package_to_module(pkg)
-                self.module_registry.excluded_modules.add(mod)
-                InstallingPackageAlert(packages=package_statuses).broadcast()
+        async def handle_set_ui_element_value(
+            request: SetUIElementValueRequest,
+        ) -> None:
+            with http_request_context(request.request):
+                await self.set_ui_element_value(request)
+            CompletedRun().broadcast()
+
+        async def handle_rename(request: RenameRequest) -> None:
+            await self.rename_file(request.filename)
+
+        async def handle_function_call(request: FunctionCallRequest) -> None:
+            status, ret, _ = await self.function_call_request(request)
+            LOGGER.debug("Function returned with status %s", status)
+            FunctionCallResult(
+                function_call_id=request.function_call_id,
+                return_value=ret,
+                status=status,
+            ).broadcast()
 
-        installed_modules = [
-            self.package_manager.package_to_module(pkg)
-            for pkg in package_statuses
-            if package_statuses[pkg] == "installed"
-        ]
+        async def handle_set_user_config(
+            request: SetUserConfigRequest,
+        ) -> None:
+            self.set_user_config(request)
 
-        # If a package was not installed at cell registration time, it won't
-        # yet be in the script metadata.
-        if self._should_update_script_metadata():
-            self._update_script_metadata(installed_modules)
+        async def handle_install_missing_packages(
+            request: InstallMissingPackagesRequest,
+        ) -> None:
+            await self.packages_callbacks.install_missing_packages(request)
+            CompletedRun().broadcast()
 
-        cells_to_run = set(
-            cid
-            for module in installed_modules
-            if (cid := self.module_registry.defining_cell(module)) is not None
+        async def handle_stop(request: StopRequest) -> None:
+            del request
+            return None
+
+        handler.register(CreationRequest, handle_instantiate)
+        handler.register(ExecuteMultipleRequest, handle_execute_multiple)
+        handler.register(ExecuteScratchpadRequest, handle_execute_scratchpad)
+        handler.register(ExecuteStaleRequest, handle_execute_stale)
+        handler.register(RenameRequest, handle_rename)
+        handler.register(SetCellConfigRequest, self.set_cell_config)
+        handler.register(SetUserConfigRequest, handle_set_user_config)
+        handler.register(SetUIElementValueRequest, handle_set_ui_element_value)
+        handler.register(FunctionCallRequest, handle_function_call)
+        handler.register(DeleteCellRequest, self.delete_cell)
+        handler.register(
+            InstallMissingPackagesRequest, handle_install_missing_packages
         )
-        if cells_to_run:
-            await self._if_autorun_then_run_cells(cells_to_run)
-
-    def _should_update_script_metadata(self) -> bool:
-        return (
-            GLOBAL_SETTINGS.MANAGE_SCRIPT_METADATA is True
-            and self.app_metadata.filename is not None
-            and self.package_manager is not None
+        handler.register(StopRequest, handle_stop)
+        # Datasets
+        handler.register(
+            PreviewDatasetColumnRequest,
+            self.datasets_callbacks.preview_dataset_column,
+        )
+        handler.register(
+            PreviewSQLTableRequest, self.datasets_callbacks.preview_sql_table
+        )
+        handler.register(
+            PreviewSQLTableListRequest,
+            self.datasets_callbacks.preview_sql_table_list,
+        )
+        # Secrets
+        handler.register(
+            ListSecretKeysRequest, self.secrets_callbacks.list_secrets
         )
 
-    def _update_script_metadata(
-        self, import_namespaces_to_add: list[str]
-    ) -> None:
-        filename = self.app_metadata.filename
+        return handler
 
-        if not filename or not self.package_manager:
-            return
+    async def handle_message(self, request: ControlRequest) -> None:
+        """Handle a message from the client.
 
-        try:
-            LOGGER.debug(
-                "Updating script metadata: %s. Adding namespaces: %s.",
-                filename,
-                import_namespaces_to_add,
-            )
-            self.package_manager.update_notebook_script_metadata(
-                filepath=filename,
-                import_namespaces_to_add=import_namespaces_to_add,
-            )
-        except Exception as e:
-            LOGGER.error("Failed to add script metadata to notebook: %s", e)
+        The message is dispatched to the appropriate method based on its type.
+
+        Coarsely locks globals to avoid race conditions with code completion.
+        """
+        # acquiring and releasing an RLock takes ~100ns; the overhead is
+        # negligible because the lock is coarse.
+        LOGGER.debug("Acquiring globals lock to handle request %s", request)
+
+        with self.lock_globals():
+            LOGGER.debug("Handling control request: %s", request)
+            await self.request_handler.handle(request)
+            LOGGER.debug("Handled control request: %s", request)
+
+
+class DatasetCallbacks:
+    def __init__(self, kernel: Kernel):
+        self._kernel = kernel
 
     @kernel_tracer.start_as_current_span("preview_dataset_column")
     async def preview_dataset_column(
@@ -2153,7 +2084,7 @@ async def preview_dataset_column(
                     column_name=column_name,
                 )
             elif source_type == "local":
-                dataset = self.globals[table_name]
+                dataset = self._kernel.globals[table_name]
                 column_preview = get_column_preview_for_dataframe(
                     dataset, request
                 )
@@ -2197,7 +2128,7 @@ def _get_sql_engine(
 
         try:
             # Should we find the existing engine instead?
-            engine_val = self.globals.get(engine_name)
+            engine_val = self._kernel.globals.get(engine_name)
             engines = get_engines_from_variables([(engine_name, engine_val)])
             if engines is None or len(engines) == 0:
                 return None, "Engine not found"
@@ -2293,66 +2224,241 @@ async def preview_sql_table_list(
                 error="Failed to get table list: " + str(e),
             )
 
-    async def handle_message(self, request: ControlRequest) -> None:
-        """Handle a message from the client.
 
-        The message is dispatched to the appropriate method based on its type.
+class SecretsCallbacks:
+    def __init__(self, kernel: Kernel):
+        self._kernel = kernel
 
-        Coarsely locks globals to avoid race conditions with code completion.
+    async def list_secrets(self, request: ListSecretKeysRequest) -> None:
+        del request
+        secrets = get_secret_keys()
+        SecretKeysResult(secrets=secrets).broadcast()
+
+
+class PackagesCallbacks:
+    def __init__(self, kernel: Kernel):
+        self._kernel = kernel
+        self.package_manager: PackageManager | None = None
+
+    def update_package_manager(self, package_manager: str) -> None:
+        if (
+            self.package_manager is None
+            or package_manager != self.package_manager.name
+        ):
+            self.package_manager = create_package_manager(package_manager)
+
+            # All marimo notebooks depend on the marimo package; if the
+            # notebook already has marimo as a dependency, or an optional
+            # dependency group with marimo, such as marimo[sql], this is a
+            # NOOP.
+            self._maybe_add_marimo_to_script_metadata()
+
+    def send_missing_packages_alert(self, missing_packages: set[str]) -> None:
+        if self.package_manager is None:
+            return
+
+        packages = list(
+            sorted(
+                pkg
+                for mod in missing_packages
+                if not self.package_manager.attempted_to_install(
+                    pkg := self.package_manager.module_to_package(mod)
+                )
+            )
+        )
+        # Deleting a cell can make the set of missing packages smaller
+        MissingPackageAlert(
+            packages=packages,
+            isolated=is_python_isolated(),
+        ).broadcast()
+
+    def missing_packages_hook(self, runner: cell_runner.Runner) -> None:
+        module_not_found_errors = [
+            e
+            for e in runner.exceptions.values()
+            if isinstance(e, (ModuleNotFoundError, ManyModulesNotFoundError))
+        ]
+
+        if len(module_not_found_errors) == 0:
+            return
+
+        if self.package_manager is None:
+            return
+
+        missing_modules: set[str] = set()
+        missing_packages: set[str] = set()
+
+        # Populate missing_modules and missing_packages
+        # from the errors
+        for e in module_not_found_errors:
+            if isinstance(e, ManyModulesNotFoundError):
+                # filter out packages that we already attempted to install
+                # to prevent an infinite loop
+                missing_packages.update(
+                    {
+                        package
+                        for package in e.package_names
+                        if not self.package_manager.attempted_to_install(
+                            package
+                        )
+                    }
+                )
+            elif e.name is not None:
+                missing_modules.add(e.name)
+
+        # Grab missing modules from module registry and from module not found errors
+        missing_modules = (
+            self._kernel.module_registry.missing_modules() | missing_modules
+        )
+
+        # Convert modules to packages
+        for mod in missing_modules:
+            pkg = self.package_manager.module_to_package(mod)
+            # filter out packages that we already attempted to install
+            # to prevent an infinite loop
+            if not self.package_manager.attempted_to_install(pkg):
+                missing_packages.add(pkg)
+
+        if not missing_packages:
+            return
+
+        packages = list(sorted(missing_packages))
+        if self.package_manager.should_auto_install():
+            version = {pkg: "" for pkg in packages}
+            self._kernel.enqueue_control_request(
+                InstallMissingPackagesRequest(
+                    manager=self.package_manager.name, versions=version
+                )
+            )
+        else:
+            MissingPackageAlert(
+                packages=packages,
+                isolated=is_python_isolated(),
+            ).broadcast()
+
+    async def install_missing_packages(
+        self, request: InstallMissingPackagesRequest
+    ) -> None:
+        """Attempts to install packages for modules that cannot be imported
+
+        Runs cells affected by successful installation.
         """
-        # acquiring and releasing an RLock takes ~100ns; the overhead is
-        # negligible because the lock is coarse.
-        LOGGER.debug("Acquiring globals lock to handle request %s", request)
-        with self.lock_globals():
-            LOGGER.debug("Handling control request: %s", request)
-            if isinstance(request, CreationRequest):
-                with http_request_context(request.request):
-                    await self.instantiate(request)
-                CompletedRun().broadcast()
-            elif isinstance(request, ExecuteMultipleRequest):
-                with http_request_context(request.request):
-                    await self.run(request.execution_requests)
-                CompletedRun().broadcast()
-            elif isinstance(request, ExecuteScratchpadRequest):
-                with http_request_context(request.request):
-                    await self.run_scratchpad(request.code)
-            elif isinstance(request, ExecuteStaleRequest):
-                await self.run_stale_cells()
-            elif isinstance(request, RenameRequest):
-                await self.rename_file(request.filename)
-            elif isinstance(request, SetCellConfigRequest):
-                await self.set_cell_config(request)
-            elif isinstance(request, SetUserConfigRequest):
-                self.set_user_config(request)
-            elif isinstance(request, SetUIElementValueRequest):
-                with http_request_context(request.request):
-                    await self.set_ui_element_value(request)
-                CompletedRun().broadcast()
-            elif isinstance(request, FunctionCallRequest):
-                status, ret, _ = await self.function_call_request(request)
-                LOGGER.debug("Function returned with status %s", status)
-                FunctionCallResult(
-                    function_call_id=request.function_call_id,
-                    return_value=ret,
-                    status=status,
-                ).broadcast()
-                CompletedRun().broadcast()
-            elif isinstance(request, DeleteCellRequest):
-                await self.delete_cell(request)
-            elif isinstance(request, InstallMissingPackagesRequest):
-                await self.install_missing_packages(request)
-                CompletedRun().broadcast()
-            elif isinstance(request, PreviewDatasetColumnRequest):
-                await self.preview_dataset_column(request)
-            elif isinstance(request, PreviewSQLTableRequest):
-                await self.preview_sql_table(request)
-            elif isinstance(request, PreviewSQLTableListRequest):
-                await self.preview_sql_table_list(request)
-            elif isinstance(request, StopRequest):
-                return None
+        assert self.package_manager is not None
+        if request.manager != self.package_manager.name:
+            # Swap out the package manager
+            self.package_manager = create_package_manager(request.manager)
+
+        if not self.package_manager.is_manager_installed():
+            self.package_manager.alert_not_installed()
+            return
+
+        missing_packages_set = set(request.versions.keys())
+        # Append all other missing packages from the notebook; the missing
+        # package request only contains the packages from the cell the user
+        # executed.
+        missing_packages_set.update(
+            [
+                self.package_manager.module_to_package(module)
+                for module in self._kernel.module_registry.missing_modules()
+            ]
+        )
+        missing_packages = list(sorted(missing_packages_set))
+
+        # Frontend shows package names, not module names
+        package_statuses: PackageStatusType = {
+            pkg: "queued" for pkg in missing_packages
+        }
+        InstallingPackageAlert(packages=package_statuses).broadcast()
+
+        for pkg in missing_packages:
+            if self.package_manager.attempted_to_install(package=pkg):
+                # Already attempted an installation; it must have failed.
+                # Skip the installation.
+                continue
+            package_statuses[pkg] = "installing"
+            InstallingPackageAlert(packages=package_statuses).broadcast()
+            version = request.versions.get(pkg)
+            if await self.package_manager.install(pkg, version=version):
+                package_statuses[pkg] = "installed"
+                InstallingPackageAlert(packages=package_statuses).broadcast()
             else:
-                raise ValueError(f"Unknown request {request}")
-            LOGGER.debug("Handled control request: %s", request)
+                package_statuses[pkg] = "failed"
+                mod = self.package_manager.package_to_module(pkg)
+                self._kernel.module_registry.excluded_modules.add(mod)
+                InstallingPackageAlert(packages=package_statuses).broadcast()
+
+        installed_modules = [
+            self.package_manager.package_to_module(pkg)
+            for pkg in package_statuses
+            if package_statuses[pkg] == "installed"
+        ]
+
+        # If a package was not installed at cell registration time, it won't
+        # yet be in the script metadata.
+        if self.should_update_script_metadata():
+            self.update_script_metadata(installed_modules)
+
+        cells_to_run = set(
+            cid
+            for module in installed_modules
+            if (cid := self._kernel.module_registry.defining_cell(module))
+            is not None
+        )
+        if cells_to_run:
+            await self._kernel._if_autorun_then_run_cells(cells_to_run)
+
+    def _maybe_add_marimo_to_script_metadata(self) -> None:
+        if self.should_update_script_metadata():
+            self.update_script_metadata(["marimo"])
+
+    def should_update_script_metadata(self) -> bool:
+        return (
+            GLOBAL_SETTINGS.MANAGE_SCRIPT_METADATA is True
+            and self._kernel.app_metadata.filename is not None
+            and self.package_manager is not None
+        )
+
+    def update_script_metadata(
+        self, import_namespaces_to_add: list[str]
+    ) -> None:
+        filename = self._kernel.app_metadata.filename
+
+        if not filename or not self.package_manager:
+            return
+
+        try:
+            LOGGER.debug(
+                "Updating script metadata: %s. Adding namespaces: %s.",
+                filename,
+                import_namespaces_to_add,
+            )
+            self.package_manager.update_notebook_script_metadata(
+                filepath=filename,
+                import_namespaces_to_add=import_namespaces_to_add,
+            )
+        except Exception as e:
+            LOGGER.error("Failed to add script metadata to notebook: %s", e)
+
+
+class RequestHandler:
+    def __init__(self) -> None:
+        self._handlers: dict[
+            type[ControlRequest],
+            Callable[[ControlRequest], Awaitable[None]],
+        ] = {}
+
+    def register(
+        self,
+        request_type: type[ControlRequest],
+        handler: Callable[[Any], Awaitable[None]],
+    ) -> None:
+        self._handlers[request_type] = handler
+
+    async def handle(self, request: ControlRequest) -> None:
+        handler = self._handlers.get(type(request))
+        if handler:
+            return await handler(request)
+        raise ValueError(f"Unknown request {request}")
 
 
 def launch_kernel(
diff --git a/marimo/_secrets/env_provider.py b/marimo/_secrets/env_provider.py
new file mode 100644
index 00000000000..6ecd87f501d
--- /dev/null
+++ b/marimo/_secrets/env_provider.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from marimo._secrets.models import SecretProvider
+
+
+class EnvSecretsProvider(SecretProvider):
+    type = "env"
+
+    def get_keys(self) -> list[str]:
+        import os
+
+        return list(os.environ.keys())
+
+    def set_key(self, key: str, value: str) -> None:
+        del key, value
+        raise NotImplementedError
+
+    def delete_key(self, key: str) -> None:
+        del key
+        raise NotImplementedError
diff --git a/marimo/_secrets/load_dotenv.py b/marimo/_secrets/load_dotenv.py
new file mode 100644
index 00000000000..dbea4184803
--- /dev/null
+++ b/marimo/_secrets/load_dotenv.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import os
+
+from marimo._dependencies.dependencies import DependencyManager
+
+
+def load_dotenv_with_fallback(file: str) -> None:
+    """Load a .env file using the dotenv library, falling to our custom
+    implementation if the dotenv library is not installed.
+    """
+    if DependencyManager.dotenv.has():
+        from dotenv import load_dotenv  # type: ignore[import-not-found]
+
+        load_dotenv(file)
+    else:
+        load_to_environ(parse_dotenv(file))
+
+
+def parse_dotenv(filepath: str) -> dict[str, str]:
+    """Parse a .env file into a dictionary of key-value pairs."""
+    env_dict: dict[str, str] = {}
+    try:
+        with open(filepath, encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                # Skip empty lines and comments
+                if not line or line.startswith("#"):
+                    continue
+
+                # Handle lines without equals sign
+                if "=" not in line:
+                    continue
+
+                key, value = line.split("=", 1)
+                key = key.strip()
+                if not key:
+                    continue
+
+                value = _drop_quotes(value.strip())
+                env_dict[key] = value
+    except FileNotFoundError:
+        # Handle case where .env file doesn't exist
+        pass
+    return env_dict
+
+
+def load_to_environ(env_dict: dict[str, str]) -> None:
+    """Load a dictionary of key-value pairs into the environment."""
+    for key, value in env_dict.items():
+        os.environ[key] = value
+
+
+def _drop_quotes(value: str) -> str:
+    # Handle quoted values (both single and double quotes)
+    if (value.startswith("'") and value.endswith("'")) or (
+        value.startswith('"') and value.endswith('"')
+    ):
+        return value[1:-1]
+
+    return value
diff --git a/marimo/_secrets/models.py b/marimo/_secrets/models.py
new file mode 100644
index 00000000000..9469acf7a7e
--- /dev/null
+++ b/marimo/_secrets/models.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Literal
+
+SecretProviderType = Literal["env"]
+
+
+@dataclass
+class SecretKeysWithProvider:
+    provider: SecretProviderType
+    keys: list[str]
+
+
+class SecretProvider(ABC):
+    type: SecretProviderType
+
+    @abstractmethod
+    def get_keys(self) -> list[str]:
+        pass
+
+    @abstractmethod
+    def set_key(self, key: str, value: str) -> None:
+        pass
+
+    @abstractmethod
+    def delete_key(self, key: str) -> None:
+        pass
diff --git a/marimo/_secrets/secrets.py b/marimo/_secrets/secrets.py
new file mode 100644
index 00000000000..520261cfa4d
--- /dev/null
+++ b/marimo/_secrets/secrets.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from marimo._secrets.env_provider import EnvSecretsProvider
+from marimo._secrets.models import SecretKeysWithProvider
+
+
+def get_secret_keys() -> list[SecretKeysWithProvider]:
+    PROVIDERS = [EnvSecretsProvider()]
+
+    results: list[SecretKeysWithProvider] = []
+    for provider in PROVIDERS:
+        results.append(
+            SecretKeysWithProvider(provider="env", keys=provider.get_keys())
+        )
+    return results
diff --git a/marimo/_server/api/endpoints/secrets.py b/marimo/_server/api/endpoints/secrets.py
new file mode 100644
index 00000000000..106e11b5a07
--- /dev/null
+++ b/marimo/_server/api/endpoints/secrets.py
@@ -0,0 +1,73 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from starlette.authentication import requires
+
+from marimo import _loggers
+from marimo._runtime.requests import ListSecretKeysRequest
+from marimo._server.api.deps import AppState
+from marimo._server.models.models import BaseResponse, SuccessResponse
+from marimo._server.router import APIRouter
+from marimo._types.ids import ConsumerId
+
+if TYPE_CHECKING:
+    from starlette.requests import Request
+
+LOGGER = _loggers.marimo_logger()
+
+# Router for secrets endpoints
+router = APIRouter()
+
+
+@router.post("/keys")
+@requires("edit")
+async def list_keys(request: Request) -> SuccessResponse:
+    """
+    responses:
+        200:
+            description: Preview a column in a dataset
+            content:
+                application/json:
+                    schema:
+                        $ref: "#/components/schemas/ListSecretKeysResponse"
+    """
+    app_state = AppState(request)
+    app_state.require_current_session().put_control_request(
+        ListSecretKeysRequest(),
+        from_consumer_id=ConsumerId(app_state.require_current_session_id()),
+    )
+    return SuccessResponse(success=True)
+
+
+@router.post("/create")
+@requires("edit")
+async def create_secret(request: Request) -> BaseResponse:
+    """
+    responses:
+        200:
+            description: Create a secret
+            content:
+                application/json:
+                    schema:
+                        $ref: "#/components/schemas/BaseResponse"
+    """
+    del request
+    raise NotImplementedError("Not implemented")
+
+
+@router.post("/delete")
+@requires("edit")
+async def delete_secret(request: Request) -> BaseResponse:
+    """
+    responses:
+        200:
+            description: Delete a secret
+            content:
+                application/json:
+                    schema:
+                        $ref: "#/components/schemas/BaseResponse"
+    """
+    del request
+    raise NotImplementedError("Not implemented")
diff --git a/marimo/_server/api/router.py b/marimo/_server/api/router.py
index b3dfb4db3ad..288f2a7d502 100644
--- a/marimo/_server/api/router.py
+++ b/marimo/_server/api/router.py
@@ -23,6 +23,7 @@
 from marimo._server.api.endpoints.home import router as home_router
 from marimo._server.api.endpoints.login import router as login_router
 from marimo._server.api.endpoints.packages import router as packages_router
+from marimo._server.api.endpoints.secrets import router as secrets_router
 from marimo._server.api.endpoints.terminal import router as terminal_router
 from marimo._server.api.endpoints.ws import router as ws_router
 from marimo._server.router import APIRouter
@@ -47,6 +48,9 @@ def build_routes(base_url: str = "") -> list[BaseRoute]:
     app_router.include_router(
         file_explorer_router, prefix="/api/files", name="file_explorer"
     )
+    app_router.include_router(
+        secrets_router, prefix="/api/secrets", name="secrets"
+    )
     app_router.include_router(
         documentation_router, prefix="/api/documentation", name="documentation"
     )
diff --git a/marimo/_server/models/secrets.py b/marimo/_server/models/secrets.py
new file mode 100644
index 00000000000..6589e5ace80
--- /dev/null
+++ b/marimo/_server/models/secrets.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from marimo._secrets.models import SecretKeysWithProvider, SecretProviderType
+
+
+@dataclass
+class ListSecretKeysResponse:
+    keys: list[SecretKeysWithProvider]
+
+
+@dataclass
+class CreateSecretRequest:
+    key: str
+    value: str
+    provider: SecretProviderType
+
+    def __post_init__(self) -> None:
+        assert len(self.key) > 0, "Key must be non-empty"
+        assert len(self.value) > 0, "Value must be non-empty"
+
+
+@dataclass
+class DeleteSecretRequest:
+    key: str
+
+    def __post_init__(self) -> None:
+        assert len(self.key) > 0, "Key must be non-empty"
diff --git a/openapi/api.yaml b/openapi/api.yaml
index 966207e3bbb..b79fac45669 100644
--- a/openapi/api.yaml
+++ b/openapi/api.yaml
@@ -387,6 +387,21 @@ components:
       - source
       - destination
       type: object
+    CreateSecretRequest:
+      properties:
+        key:
+          type: string
+        provider:
+          enum:
+          - env
+          type: string
+        value:
+          type: string
+      required:
+      - key
+      - value
+      - provider
+      type: object
     CycleError:
       properties:
         edges_with_vars:
@@ -650,6 +665,13 @@ components:
       - cells
       - type
       type: object
+    DeleteSecretRequest:
+      properties:
+        key:
+          type: string
+      required:
+      - key
+      type: object
     Error:
       oneOf:
       - $ref: '#/components/schemas/CycleError'
@@ -695,7 +717,10 @@ components:
       - code
       type: object
     ExecuteStaleRequest:
-      properties: {}
+      properties:
+        request:
+          $ref: '#/components/schemas/HTTPRequest'
+          nullable: true
       type: object
     ExecutionRequest:
       properties:
@@ -1252,6 +1277,27 @@ components:
       required:
       - packages
       type: object
+    ListSecretKeysResponse:
+      properties:
+        keys:
+          items:
+            properties:
+              keys:
+                items:
+                  type: string
+                type: array
+              provider:
+                enum:
+                - env
+                type: string
+            required:
+            - provider
+            - keys
+            type: object
+          type: array
+      required:
+      - keys
+      type: object
     MIME:
       properties: {}
       type: object
@@ -3105,6 +3151,33 @@ paths:
               schema:
                 $ref: '#/components/schemas/PackageOperationResponse'
           description: Uninstall package
+  /api/secrets/create:
+    post:
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/BaseResponse'
+          description: Create a secret
+  /api/secrets/delete:
+    post:
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/BaseResponse'
+          description: Delete a secret
+  /api/secrets/keys:
+    post:
+      responses:
+        200:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ListSecretKeysResponse'
+          description: Preview a column in a dataset
   /api/status:
     get:
       responses:
diff --git a/openapi/src/api.ts b/openapi/src/api.ts
index 750da6d3656..b1764d81537 100644
--- a/openapi/src/api.ts
+++ b/openapi/src/api.ts
@@ -1997,6 +1997,111 @@ export interface paths {
     patch?: never;
     trace?: never;
   };
+  "/api/secrets/create": {
+    parameters: {
+      query?: never;
+      header?: never;
+      path?: never;
+      cookie?: never;
+    };
+    get?: never;
+    put?: never;
+    post: {
+      parameters: {
+        query?: never;
+        header?: never;
+        path?: never;
+        cookie?: never;
+      };
+      requestBody?: never;
+      responses: {
+        /** @description Create a secret */
+        200: {
+          headers: {
+            [name: string]: unknown;
+          };
+          content: {
+            "application/json": components["schemas"]["BaseResponse"];
+          };
+        };
+      };
+    };
+    delete?: never;
+    options?: never;
+    head?: never;
+    patch?: never;
+    trace?: never;
+  };
+  "/api/secrets/delete": {
+    parameters: {
+      query?: never;
+      header?: never;
+      path?: never;
+      cookie?: never;
+    };
+    get?: never;
+    put?: never;
+    post: {
+      parameters: {
+        query?: never;
+        header?: never;
+        path?: never;
+        cookie?: never;
+      };
+      requestBody?: never;
+      responses: {
+        /** @description Delete a secret */
+        200: {
+          headers: {
+            [name: string]: unknown;
+          };
+          content: {
+            "application/json": components["schemas"]["BaseResponse"];
+          };
+        };
+      };
+    };
+    delete?: never;
+    options?: never;
+    head?: never;
+    patch?: never;
+    trace?: never;
+  };
+  "/api/secrets/keys": {
+    parameters: {
+      query?: never;
+      header?: never;
+      path?: never;
+      cookie?: never;
+    };
+    get?: never;
+    put?: never;
+    post: {
+      parameters: {
+        query?: never;
+        header?: never;
+        path?: never;
+        cookie?: never;
+      };
+      requestBody?: never;
+      responses: {
+        /** @description Preview a column in a dataset */
+        200: {
+          headers: {
+            [name: string]: unknown;
+          };
+          content: {
+            "application/json": components["schemas"]["ListSecretKeysResponse"];
+          };
+        };
+      };
+    };
+    delete?: never;
+    options?: never;
+    head?: never;
+    patch?: never;
+    trace?: never;
+  };
   "/api/status": {
     parameters: {
       query?: never;
@@ -2249,6 +2354,7 @@ export interface components {
       variant?: "danger" | null;
     };
     AppMetadata: {
+      argv?: string[] | null;
       cliArgs: {
         [key: string]:
           | string
@@ -2376,6 +2482,12 @@ export interface components {
       destination: string;
       source: string;
     };
+    CreateSecretRequest: {
+      key: string;
+      /** @enum {string} */
+      provider: "env";
+      value: string;
+    };
     CycleError: {
       edges_with_vars: [string, string[], string][];
       /** @enum {string} */
@@ -2468,6 +2580,9 @@ export interface components {
       /** @enum {string} */
       type: "delete-nonlocal";
     };
+    DeleteSecretRequest: {
+      key: string;
+    };
     Error:
       | components["schemas"]["CycleError"]
       | components["schemas"]["MultipleDefinitionError"]
@@ -2491,7 +2606,9 @@ export interface components {
       code: string;
       request?: components["schemas"]["HTTPRequest"];
     };
-    ExecuteStaleRequest: Record<string, never>;
+    ExecuteStaleRequest: {
+      request?: components["schemas"]["HTTPRequest"];
+    };
     ExecutionRequest: {
       cellId: string;
       code: string;
@@ -2747,6 +2864,13 @@ export interface components {
     ListPackagesResponse: {
       packages: components["schemas"]["PackageDescription"][];
     };
+    ListSecretKeysResponse: {
+      keys: {
+        keys: string[];
+        /** @enum {string} */
+        provider: "env";
+      }[];
+    };
     MIME: Record<string, never>;
     MarimoAncestorPreventedError: {
       blamed_cell?: string | null;
diff --git a/tests/_runtime/test_dotenv.py b/tests/_runtime/test_dotenv.py
index 42d496d06aa..878774b2649 100644
--- a/tests/_runtime/test_dotenv.py
+++ b/tests/_runtime/test_dotenv.py
@@ -6,136 +6,93 @@
 import pytest
 
 from marimo._config.config import merge_default_config
-from marimo._dependencies.dependencies import DependencyManager
 from marimo._runtime.requests import (
     CreationRequest,
     SetUIElementValueRequest,
 )
+from marimo._utils.platform import is_windows
 from tests.conftest import MockedKernel
 
+# These tests work with and without python-dotenv installed.
+
 
 @pytest.mark.skipif(
-    not DependencyManager.dotenv.has(), reason="dotenv is not installed"
+    is_windows(),
+    reason="Does not work on Windows",
 )
-class TestDotEnv:
-    def test_load_dotenv_success(self, mocked_kernel: MockedKernel):
-        # Create a temporary .env file
-        with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file:
-            env_file.write("TEST_VAR=test_value\n")
-            env_file.write("ANOTHER_VAR=another_value\n")
-            env_file.flush()
-
-            # Create a custom config with the path to our temp .env file
-            custom_config = merge_default_config(
-                {
-                    "runtime": {
-                        "dotenv": [env_file.name],
-                    },
-                }
-            )
-            mocked_kernel.k.user_config = custom_config
-
-            # Load the .env file
-            mocked_kernel.k.load_dotenv()
-
-            # Check that the environment variables were set
-            assert os.environ.get("TEST_VAR") == "test_value"
-            assert os.environ.get("ANOTHER_VAR") == "another_value"
-
-    def test_load_dotenv_nonexistent_file(self, mocked_kernel: MockedKernel):
-        # Create a config with a nonexistent .env file
+def test_load_dotenv_success(mocked_kernel: MockedKernel):
+    # Create a temporary .env file
+    with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file:
+        env_file.write("TEST_VAR=test_value\n")
+        env_file.write("ANOTHER_VAR=another_value\n")
+        env_file.flush()
+
+        # Create a custom config with the path to our temp .env file
         custom_config = merge_default_config(
             {
                 "runtime": {
-                    "dotenv": ["nonexistent.env"],
+                    "dotenv": [env_file.name],
                 },
             }
         )
         mocked_kernel.k.user_config = custom_config
 
-        # Create a kernel with our custom config
-        # This should not raise an exception
+        # Load the .env file
         mocked_kernel.k.load_dotenv()
 
-    async def test_load_dotenv_on_instantiate(
-        self, mocked_kernel: MockedKernel
-    ):
-        # Create a temporary .env file
-        with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file:
-            env_file.write("INSTANTIATE_TEST_VAR=instantiate_value\n")
-            env_file.flush()
-
-            # Create a custom config with the path to our temp .env file
-            custom_config = merge_default_config(
-                {
-                    "runtime": {
-                        "dotenv": [env_file.name],
-                    },
-                }
-            )
-            mocked_kernel.k.user_config = custom_config
-
-            # Create a CreationRequest
-            request = CreationRequest(
-                execution_requests=(),
-                set_ui_element_value_request=SetUIElementValueRequest(
-                    object_ids=[],
-                    values=[],
-                ),
-                auto_run=True,
-            )
-
-            # Call instantiate, which should call load_dotenv
-            await mocked_kernel.k.instantiate(request)
-
-            # Check that the environment variable was set
-            assert (
-                os.environ.get("INSTANTIATE_TEST_VAR") == "instantiate_value"
-            )
+        # Check that the environment variables were set
+        assert os.environ.get("TEST_VAR") == "test_value"
+        assert os.environ.get("ANOTHER_VAR") == "another_value"
+
+
+def test_load_dotenv_nonexistent_file(mocked_kernel: MockedKernel):
+    # Create a config with a nonexistent .env file
+    custom_config = merge_default_config(
+        {
+            "runtime": {
+                "dotenv": ["nonexistent.env"],
+            },
+        }
+    )
+    mocked_kernel.k.user_config = custom_config
+
+    # Create a kernel with our custom config
+    # This should not raise an exception
+    mocked_kernel.k.load_dotenv()
 
 
 @pytest.mark.skipif(
-    DependencyManager.dotenv.has(), reason="dotenv should not be installed"
+    is_windows(),
+    reason="Does not work on Windows",
 )
-class TestDotEnvNotInstalled:
-    def test_load_dotenv_import_error(self, mocked_kernel: MockedKernel):
-        # This should not raise an exception
-        mocked_kernel.k.load_dotenv()
+async def test_load_dotenv_on_instantiate(mocked_kernel: MockedKernel):
+    # Create a temporary .env file
+    with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file:
+        env_file.write("INSTANTIATE_TEST_VAR=instantiate_value\n")
+        env_file.flush()
+
+        # Create a custom config with the path to our temp .env file
+        custom_config = merge_default_config(
+            {
+                "runtime": {
+                    "dotenv": [env_file.name],
+                },
+            }
+        )
+        mocked_kernel.k.user_config = custom_config
+
+        # Create a CreationRequest
+        request = CreationRequest(
+            execution_requests=(),
+            set_ui_element_value_request=SetUIElementValueRequest(
+                object_ids=[],
+                values=[],
+            ),
+            auto_run=True,
+        )
+
+        # Call instantiate, which should call load_dotenv
+        await mocked_kernel.k.instantiate(request)
 
-        # No environment variables should be set
-        assert os.environ.get("TEST_VAR") is None
-        assert os.environ.get("ANOTHER_VAR") is None
-
-    async def test_instantiate_without_dotenv(
-        self, mocked_kernel: MockedKernel
-    ):
-        # Create a temporary .env file
-        with tempfile.NamedTemporaryFile(suffix=".env", mode="w+") as env_file:
-            env_file.write("ENV_VAR_SHOULD_NOT_LOAD=test_value\n")
-            env_file.flush()
-
-            # Create a custom config with the path to our temp .env file
-            custom_config = merge_default_config(
-                {
-                    "runtime": {
-                        "dotenv": [env_file.name],
-                    },
-                }
-            )
-            mocked_kernel.k.user_config = custom_config
-
-            # Create a CreationRequest
-            request = CreationRequest(
-                execution_requests=(),
-                set_ui_element_value_request=SetUIElementValueRequest(
-                    object_ids=[],
-                    values=[],
-                ),
-                auto_run=True,
-            )
-
-            # Call instantiate, which tries to call load_dotenv
-            await mocked_kernel.k.instantiate(request)
-
-            # Verify that no environment variables were set
-            assert os.environ.get("ENV_VAR_SHOULD_NOT_LOAD") is None
+        # Check that the environment variable was set
+        assert os.environ.get("INSTANTIATE_TEST_VAR") == "instantiate_value"
diff --git a/tests/_runtime/test_manage_script_metadata.py b/tests/_runtime/test_manage_script_metadata.py
index 2aa9211cf71..d6668253795 100644
--- a/tests/_runtime/test_manage_script_metadata.py
+++ b/tests/_runtime/test_manage_script_metadata.py
@@ -233,7 +233,7 @@ async def test_install_missing_packages_micropip(
     sys.modules["pyodide"] = ModuleType("pyodide")
 
     with patch("micropip.install", new_callable=AsyncMock) as mock_install:
-        await k.install_missing_packages(
+        await k.packages_callbacks.install_missing_packages(
             InstallMissingPackagesRequest(
                 manager="micropip",
                 versions={"barbaz": "", "foobar": ""},
@@ -257,7 +257,7 @@ async def test_install_missing_packages_micropip_with_versions(
     sys.modules["pyodide"] = ModuleType("pyodide")
 
     with patch("micropip.install", new_callable=AsyncMock) as mock_install:
-        await k.install_missing_packages(
+        await k.packages_callbacks.install_missing_packages(
             InstallMissingPackagesRequest(
                 manager="micropip",
                 versions={"numpy": "1.22.0", "pandas": "1.5.0"},
@@ -285,7 +285,7 @@ async def test_install_missing_packages_micropip_other_modules(
     sys.modules["already_installed"] = ModuleType("already_installed")
 
     with patch("micropip.install", new_callable=AsyncMock) as mock_install:
-        await k.install_missing_packages(
+        await k.packages_callbacks.install_missing_packages(
             InstallMissingPackagesRequest(
                 manager="micropip",
                 versions={},
@@ -302,10 +302,10 @@ async def test_install_missing_packages_micropip_other_modules(
     del sys.modules["already_installed"]
 
 
-async def test_broadcast_missing_packages(
+async def test_missing_packages_hook(
     mocked_kernel: MockedKernel,
 ) -> None:
-    """Test that _broadcast_missing_packages correctly handles missing packages for micropip"""
+    """Test that missing_packages_hook correctly handles missing packages for micropip"""
     k = mocked_kernel.k
     control_requests: list[ControlRequest] = []
     broadcast_messages: list[InstallingPackageAlert | MissingPackageAlert] = []
@@ -348,10 +348,12 @@ def __init__(self) -> None:
     # Case 1: Auto-install enabled
     with patch("micropip.install", new_callable=AsyncMock):
         runner = cast(cell_runner.Runner, MockRunner())
-        k.package_manager = create_package_manager("micropip")
-        package_manager = k.package_manager
+        k.packages_callbacks.package_manager = create_package_manager(
+            "micropip"
+        )
+        package_manager = k.packages_callbacks.package_manager
         assert isinstance(package_manager, MicropipPackageManager)
-        k._broadcast_missing_packages(runner)
+        k.packages_callbacks.missing_packages_hook(runner)
 
         # Should create install request
         assert len(control_requests) == 1
@@ -370,7 +372,7 @@ def __init__(self) -> None:
         control_requests.clear()
         broadcast_messages.clear()
         package_manager.should_auto_install = lambda: False  # type: ignore
-        k._broadcast_missing_packages(runner)
+        k.packages_callbacks.missing_packages_hook(runner)
 
         # Should broadcast alert instead of installing
         assert len(control_requests) == 0
@@ -394,7 +396,7 @@ def __init__(self) -> None:
             "scipy",
         }  # type: ignore
         package_manager.should_auto_install = lambda: True  # type: ignore
-        k._broadcast_missing_packages(runner)
+        k.packages_callbacks.missing_packages_hook(runner)
 
         # Should create install request with all missing packages
         assert len(control_requests) == 1
@@ -416,7 +418,7 @@ def __init__(self) -> None:
         package_manager.attempted_to_install = (
             lambda package: package == "numpy"
         )  # type: ignore
-        k._broadcast_missing_packages(runner)
+        k.packages_callbacks.missing_packages_hook(runner)
 
         # Should only include packages not yet attempted
         assert len(control_requests) == 1
@@ -434,10 +436,10 @@ def __init__(self) -> None:
     del sys.modules["pyodide"]
 
 
-def test_broadcast_missing_packages_pip(
+def test_missing_packages_hook_pip(
     mocked_kernel: MockedKernel,
 ) -> None:
-    """Test that _broadcast_missing_packages correctly handles missing packages for pip"""
+    """Test that missing_packages_hook correctly handles missing packages for pip"""
     k = mocked_kernel.k
     control_requests: list[ControlRequest] = []
     broadcast_messages: list[InstallingPackageAlert | MissingPackageAlert] = []
@@ -471,8 +473,8 @@ def __init__(self) -> None:
                 ),
             }
 
-    k.package_manager = create_package_manager("pip")
-    package_manager = k.package_manager
+    k.packages_callbacks.package_manager = create_package_manager("pip")
+    package_manager = k.packages_callbacks.package_manager
     assert isinstance(package_manager, PipPackageManager)
     package_manager.install = AsyncMock()
     runner = cast(cell_runner.Runner, MockRunner())
@@ -480,7 +482,7 @@ def __init__(self) -> None:
     # Case 1: Missing modules with auto-install disabled
     k.module_registry.missing_modules = lambda: {"numpy", "pandas"}  # type: ignore
     package_manager.should_auto_install = lambda: False  # type: ignore
-    k._broadcast_missing_packages(runner)
+    k.packages_callbacks.missing_packages_hook(runner)
 
     # Should broadcast alert instead of installing
     assert len(control_requests) == 0
@@ -500,7 +502,7 @@ def __init__(self) -> None:
         "scipy",
     }  # type: ignore
     package_manager.should_auto_install = lambda: True  # type: ignore
-    k._broadcast_missing_packages(runner)
+    k.packages_callbacks.missing_packages_hook(runner)
 
     # Should create install request with all missing packages
     assert len(control_requests) == 1
diff --git a/tests/_runtime/test_runtime_secrets.py b/tests/_runtime/test_runtime_secrets.py
new file mode 100644
index 00000000000..2bbed4e91d3
--- /dev/null
+++ b/tests/_runtime/test_runtime_secrets.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import os
+
+import pytest
+
+from marimo._messaging.ops import SecretKeysResult
+from marimo._runtime.requests import ListSecretKeysRequest
+from marimo._runtime.runtime import SecretsCallbacks
+from marimo._utils.parse_dataclass import parse_raw
+from tests._runtime.test_runtime import MockedKernel
+
+
+@pytest.fixture
+def secrets_callbacks(mocked_kernel: MockedKernel):
+    return SecretsCallbacks(mocked_kernel.k)
+
+
+async def test_list_secrets_with_values(
+    secrets_callbacks: SecretsCallbacks, mocked_kernel: MockedKernel
+):
+    # Set some test secrets
+    test_secrets = ["DUMMY_SECRET"]
+    os.environ["DUMMY_SECRET"] = "dummy-value"
+
+    await secrets_callbacks.list_secrets(ListSecretKeysRequest())
+
+    # Check that the broadcast message was sent with the correct secrets
+    messages = [
+        msg
+        for msg in mocked_kernel.stream.messages
+        if msg[0] == "secret-keys-result"
+    ]
+    assert len(messages) == 1
+    secret_messages = parse_raw(messages[0][1], SecretKeysResult)
+    assert len(secret_messages.secrets) == 1
+    assert secret_messages.secrets[0].provider == "env"
+    assert all(
+        secret in secret_messages.secrets[0].keys for secret in test_secrets
+    )
+
+    # Clean up
+    for key in test_secrets:
+        del os.environ[key]
diff --git a/tests/_secrets/test_load_dotenv.py b/tests/_secrets/test_load_dotenv.py
new file mode 100644
index 00000000000..368a5d3e0d7
--- /dev/null
+++ b/tests/_secrets/test_load_dotenv.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING
+
+from marimo._secrets.load_dotenv import (
+    _drop_quotes,
+    load_dotenv_with_fallback,
+    load_to_environ,
+    parse_dotenv,
+)
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+def test_drop_quotes():
+    assert _drop_quotes('"value"') == "value"
+    assert _drop_quotes("'value'") == "value"
+    assert _drop_quotes("value") == "value"
+    assert _drop_quotes('"value') == '"value'
+    assert _drop_quotes("value'") == "value'"
+
+
+def test_parse_dotenv(tmp_path: Path):
+    env_file = tmp_path / ".env"
+    env_file.write_text(
+        """
+# Comment
+KEY1=value1
+KEY2="value2"
+KEY3='value3'
+KEY4=
+KEY5="value5
+KEY6=value6"
+"""
+    )
+
+    env_dict = parse_dotenv(env_file.as_posix())
+    assert env_dict == {
+        "KEY1": "value1",
+        "KEY2": "value2",
+        "KEY3": "value3",
+        "KEY4": "",
+        "KEY5": '"value5',
+        "KEY6": 'value6"',
+    }
+
+
+def test_parse_dotenv_nonexistent():
+    env_dict = parse_dotenv("nonexistent.env")
+    assert env_dict == {}
+
+
+def test_load_to_environ():
+    env_dict = {"TEST_KEY": "test_value"}
+    load_to_environ(env_dict)
+    assert os.environ["TEST_KEY"] == "test_value"
+    del os.environ["TEST_KEY"]
+
+
+def test_load_dotenv_with_fallback(tmp_path: Path):
+    # Should work regardless of whether dotenv is installed
+    env_file = tmp_path / ".env"
+    env_file.write_text("TEST_KEY=test_value")
+
+    load_dotenv_with_fallback(env_file.as_posix())
+    assert os.environ["TEST_KEY"] == "test_value"
+    del os.environ["TEST_KEY"]


Reviews:
user: login: dmadisetti
body: We have no https middleware right? Might be worth reminding users http is insecure to MITM. But that could be documentation or FE side
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-28T23:03:52Z
user: login: akshayka
body: Two small typos ow looks good
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T17:35:01Z

Review comments:
diff_hunk: @@ -302,10 +302,10 @@ async def test_install_missing_packages_micropip_other_modules(
     del sys.modules["already_installed"]
 
 
-async def test_broadcast_missing_packages(
+async def testmissing_packages_hook(
path: tests/_runtime/test_manage_script_metadata.py
user: login: akshayka
body: `test_missing`
created_at: 2025-03-29T17:25:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -434,10 +436,10 @@ def __init__(self) -> None:
     del sys.modules["pyodide"]
 
 
-def test_broadcast_missing_packages_pip(
+def testmissing_packages_hook_pip(
path: tests/_runtime/test_manage_script_metadata.py
user: login: akshayka
body: `test_missing`
created_at: 2025-03-29T17:26:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-28T19:35:44Z
body: [vc]: #osh7q3f8ITP68ms/vcxi3XkbN6xdtwJh4QL9ZW5hgeE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYXJpbW8tc3Rvcnlib29rIiwicm9vdERpcmVjdG9yeSI6ImZyb250ZW5kIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21hcmltby9tYXJpbW8tc3Rvcnlib29rL0R1VTU1VUtHekd4RzZBdXdKQmNidnBuNXZBa04iLCJwcmV2aWV3VXJsIjoibWFyaW1vLXN0b3J5Ym9vay1naXQtbXMtc2VjcmV0cy1tYXJpbW8udmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtYXJpbW8tc3Rvcnlib29rLWdpdC1tcy1zZWNyZXRzLW1hcmltby52ZXJjZWwuYXBwIn19LHsibmFtZSI6Im1hcmltby1kb2NzIiwicm9vdERpcmVjdG9yeSI6ImRvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbWFyaW1vL21hcmltby1kb2NzL0VYdjNHdkZaYXNNRGhKTHpUd3AzcEFMRThocFgiLCJwcmV2aWV3VXJsIjoibWFyaW1vLWRvY3MtZ2l0LW1zLXNlY3JldHMtbWFyaW1vLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoibWFyaW1vLWRvY3MtZ2l0LW1zLXNlY3JldHMtbWFyaW1vLnZlcmNlbC5hcHAifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **marimo-docs** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-docs/EXv3GvFZasMDhJLzTwp3pALE8hpX)) | [Visit Preview](https://marimo-docs-git-ms-secrets-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-docs-git-ms-secrets-marimo.vercel.app?via=pr-comment-feedback-link) | Mar 29, 2025 9:12pm |
| **marimo-storybook** | ✅ Ready ([Inspect](https://vercel.com/marimo/marimo-storybook/DuU55UKGzGxG6AuwJBcbvpn5vAkN)) | [Visit Preview](https://marimo-storybook-git-ms-secrets-marimo.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/marimo-storybook-git-ms-secrets-marimo.vercel.app?via=pr-comment-feedback-link) | Mar 29, 2025 9:12pm |



author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-28T19:36:11Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T18:48:59Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T18:50:16Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T20:13:24Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T20:50:10Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T20:57:07Z
body: Breaking changes detected in the OpenAPI specification!


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-29T21:12:54Z
body: 🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.11.32-dev8
author_association: NONE