Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e0e23dc
feat: sandboxed home
dmadisetti Jan 6, 2026
2edd7cd
feat: lazy snapshot hydration
dmadisetti Jan 6, 2026
9ef054f
tidy: remove old changes
dmadisetti Jan 6, 2026
cb5f340
cleanup: refactor + tests
dmadisetti Jan 6, 2026
3b8be5e
nit
dmadisetti Jan 6, 2026
2c7f955
fix: do not run sandbox home in regu;ar sandbox mode
dmadisetti Jan 7, 2026
368cd60
comments: suggestions from code review
dmadisetti Jan 7, 2026
a02e27c
feat: push startup failure errors
dmadisetti Jan 7, 2026
98bbf71
Merge branch 'dm/sandbox-home' of github:marimo-team/marimo into dm/s…
dmadisetti Jan 7, 2026
9781908
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 7, 2026
98d29d9
tidy: cleanup ipcqueueimpl
dmadisetti Jan 7, 2026
2d08fe1
fix: missing file
dmadisetti Jan 7, 2026
bbf17b4
Merge branch 'dm/sandbox-home' of github:marimo-team/marimo into dm/s…
dmadisetti Jan 7, 2026
ca240a7
lock: pixi
dmadisetti Jan 7, 2026
0e6dc2a
comments: reduce 'home sandbox mode' language
dmadisetti Jan 7, 2026
dc69287
fix: cli tests
dmadisetti Jan 7, 2026
f29b40b
fix: misplaced import
dmadisetti Jan 7, 2026
19dc74d
fix: codegen
dmadisetti Jan 7, 2026
17a1a81
fix: test
dmadisetti Jan 7, 2026
2c17667
fix: pixi update
dmadisetti Jan 7, 2026
fda45e9
Merge branch 'main' of github:marimo-team/marimo into dm/sandbox-home
dmadisetti Jan 7, 2026
ed02912
fix: cleanup sandboxes
dmadisetti Jan 7, 2026
facafd6
fix: formatting and boolean logic
dmadisetti Jan 7, 2026
09bd928
fix: cli tests
dmadisetti Jan 7, 2026
dd3220f
fix: cli tests deps
dmadisetti Jan 7, 2026
56d854d
comment: has.zmq
dmadisetti Jan 7, 2026
97aef54
comment: sandbox modes
dmadisetti Jan 7, 2026
80bb736
merge res
dmadisetti Jan 9, 2026
988dae2
cleanup: move out format logic
dmadisetti Jan 9, 2026
9646097
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 9, 2026
dd68f7f
Update frontend/src/components/editor/KernelStartupErrorModal.tsx
dmadisetti Jan 9, 2026
6bbd338
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 9, 2026
066fa38
fix: break formatting error loop
dmadisetti Jan 9, 2026
630d985
fix: apply marimo injection in new file cases
dmadisetti Jan 9, 2026
b8c4849
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 9, 2026
0c5cb89
fix: ruff check
dmadisetti Jan 9, 2026
b37e02a
Merge branch 'dm/sandbox-home' of github:marimo-team/marimo into dm/s…
dmadisetti Jan 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 19 additions & 25 deletions marimo/_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,19 +326,6 @@ def _get_stdin_contents() -> str | None:
type=bool,
help=sandbox_message,
)
@click.option(
"--dangerous-sandbox/--no-dangerous-sandbox",
is_flag=True,
default=None,
show_default=False,
type=bool,
hidden=True,
help="""Enables the usage of package sandboxing when running a multi-edit
notebook server. This behavior can lead to surprising and unintended consequences,
such as incorrectly overwriting package requirements or failing to write out
requirements. These and other issues are described in
https://github.com/marimo-team/marimo/issues/5219.""",
)
@click.option(
"--trusted/--untrusted",
is_flag=True,
Expand Down Expand Up @@ -428,7 +415,6 @@ def edit(
allow_origins: Optional[tuple[str, ...]],
skip_update_check: bool,
sandbox: Optional[bool],
dangerous_sandbox: Optional[bool],
trusted: Optional[bool],
profile_dir: Optional[str],
watch: bool,
Expand All @@ -442,7 +428,7 @@ def edit(
name: Optional[str],
args: tuple[str, ...],
) -> None:
from marimo._cli.sandbox import run_in_sandbox, should_run_in_sandbox
from marimo._cli.sandbox import is_home_sandbox_mode, should_run_in_sandbox

pass_on_stdin = token_password_file == "-"
# We support unix-style piping, e.g. cat notebook.py | marimo edit
Expand Down Expand Up @@ -508,14 +494,22 @@ def edit(

# We check this after name validation, because this will convert
# URLs into local file paths
if should_run_in_sandbox(
sandbox=sandbox, dangerous_sandbox=dangerous_sandbox, name=name
):
from marimo._cli.sandbox import run_in_sandbox

# TODO: consider adding recommended as well
run_in_sandbox(sys.argv[1:], name=name, additional_features=["lsp"])
return
# Resolve sandbox mode
sandbox_mode = should_run_in_sandbox(sandbox=sandbox, name=name)

# Check if this is home sandbox mode (directory + sandbox)
home_sandbox = is_home_sandbox_mode(sandbox_mode, name)
if home_sandbox:
# Check for pyzmq dependency
from marimo._dependencies.dependencies import DependencyManager

if not DependencyManager.has("zmq"):
raise click.UsageError(
"pyzmq is required for sandbox home mode.\n"
"Install it with: pip install 'marimo[sandbox]'\n"
"Or: pip install pyzmq"
)

start(
file_router=AppFileRouter.infer(name),
Expand Down Expand Up @@ -545,6 +539,8 @@ def edit(
server_startup_command=server_startup_command,
asset_url=asset_url,
timeout=timeout,
sandbox_mode=sandbox_mode,
home_sandbox_mode=home_sandbox,
)


Expand Down Expand Up @@ -976,9 +972,7 @@ def run(

# We check this after name validation, because this will convert
# URLs into local file paths
if should_run_in_sandbox(
sandbox=sandbox, dangerous_sandbox=None, name=name
):
if should_run_in_sandbox(sandbox=sandbox, name=name):
run_in_sandbox(sys.argv[1:], name=name)
return

Expand Down
248 changes: 202 additions & 46 deletions marimo/_cli/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
import atexit
import os
import platform
import shutil
import signal
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Literal, Optional
from typing import Literal, Optional

import click

Expand Down Expand Up @@ -77,58 +78,44 @@ def maybe_prompt_run_in_sandbox(name: str | None) -> bool:
return False


def should_run_in_sandbox(
sandbox: bool | None, dangerous_sandbox: bool | None, name: str | None
) -> bool:
def should_run_in_sandbox(sandbox: bool | None, name: str | None) -> bool:
"""Return whether the named notebook should be run in a sandbox.

Prompts the user if sandbox is None and the notebook has sandbox metadata.
Prompts the user if sandbox is None and the notebook has sandbox metadata
(only for single notebook, not directories).

The `sandbox` arg is whether the user requested sandbox. Even
if running in sandbox was requested, it may not be allowed
if the target is a directory (unless overridden by `dangerous_sandbox`).
With IPC-based kernel architecture (home sandbox mode), each notebook gets
its own sandboxed kernel, so multi-notebook servers are now supported with
--sandbox.
"""

# Dangerous sandbox can be forced on by setting an environment variable;
# this allows our VS Code extension to force sandbox regardless of the
# marimo version.
if sandbox and os.getenv("MARIMO_DANGEROUS_SANDBOX"):
dangerous_sandbox = True

if dangerous_sandbox and (name is None or os.path.isdir(name)):
sandbox = True
click.echo(
click.style(
"Warning: Using sandbox with multi-notebook edit servers is dangerous.\n",
fg="yellow",
)
+ "Notebook dependencies may not be respected, may not be written, and may be overwritten.\n"
+ "Learn more: https://github.com/marimo-team/marimo/issues/5219l.\n",
err=True,
)

# When the sandbox flag is omitted we infer whether to
# to start in sandbox mode by examining the notebook file and
# prompting the user.
# start in sandbox mode by examining the notebook file and
# prompting the user. Only prompt for single notebooks, not directories.
if sandbox is None:
sandbox = maybe_prompt_run_in_sandbox(name)
# Don't prompt for directories - user must explicitly pass --sandbox
if name is not None and not os.path.isdir(name):
sandbox = maybe_prompt_run_in_sandbox(name)
else:
sandbox = False

# Validation: we don't yet support multi-notebook sandboxed servers.
if (
sandbox
and not dangerous_sandbox
and (name is None or os.path.isdir(name))
):
raise click.UsageError(
"""marimo's package sandbox requires a notebook name:
return sandbox

* marimo edit --sandbox my_notebook.py

Multi-notebook sandboxed servers (marimo edit --sandbox) are not supported.
Follow this issue at: https://github.com/marimo-team/marimo/issues/2598."""
)
def is_home_sandbox_mode(sandbox: bool, name: str | None) -> bool:
"""Check if we should use IPC kernel for home sandbox mode.

return sandbox
Home sandbox mode activates when:
- sandbox flag is True
- AND name is a directory OR name is None (current directory)

This mode uses IPC kernels with ZeroMQ for per-notebook sandboxed
environments.
"""
if not sandbox:
return False
# name is None means current directory (home page)
# or name is explicitly a directory
return name is None or os.path.isdir(name)


def _is_versioned(dependency: str) -> bool:
Expand Down Expand Up @@ -299,8 +286,6 @@ def construct_uv_command(
cmd = ["marimo"] + args
if "--sandbox" in cmd:
cmd.remove("--sandbox")
if "--dangerous-sandbox" in cmd:
cmd.remove("--dangerous-sandbox")

pyproject = (
PyProjectReader.from_filename(name)
Expand Down Expand Up @@ -366,6 +351,15 @@ def run_in_sandbox(
additional_features: Optional[list[DepFeatures]] = None,
additional_deps: Optional[list[str]] = None,
) -> int:
"""Run marimo in a sandboxed uv environment.

This wraps the marimo command with `uv run` to create an isolated
virtual environment with the notebook's dependencies.

Used for single-notebook sandbox mode (marimo edit --sandbox notebook.py).
For home sandbox mode (directory), see IPCKernelManagerImpl which
creates per-notebook sandboxed kernels.
"""
# If we fall back to the plain "uv" path, ensure it's actually on the system
if find_uv_bin() == "uv" and not DependencyManager.which("uv"):
raise click.UsageError("uv must be installed to use --sandbox")
Expand All @@ -384,7 +378,7 @@ def run_in_sandbox(

process = subprocess.Popen(uv_cmd, env=env)

def handler(sig: int, frame: Any) -> None:
def handler(sig: int, frame: object) -> None:
del sig
del frame
try:
Expand All @@ -399,3 +393,165 @@ def handler(sig: int, frame: Any) -> None:
signal.signal(signal.SIGINT, handler)

return process.wait()


# Dependencies required for IPC kernel communication (ZeroMQ-based)
IPC_KERNEL_DEPS: list[str] = ["pyzmq", "msgspec"]


def get_sandbox_requirements(
filename: str | None,
additional_deps: list[str] | None = None,
) -> list[str]:
"""Get normalized requirements for sandbox venv.

Reads dependencies from the notebook's PEP 723 script metadata,
normalizes marimo dependency, and adds any additional deps
(e.g., IPC_KERNEL_DEPS for kernel communication).

Args:
filename: Path to notebook file, or None for empty deps.
additional_deps: Extra dependencies to add if not already present.

Returns:
List of normalized requirement strings.
"""
pyproject = (
PyProjectReader.from_filename(filename)
if filename is not None
else PyProjectReader({}, config_path=None)
)

dependencies = _resolve_requirements_txt_lines(pyproject)
normalized = _normalize_sandbox_dependencies(
dependencies, __version__, additional_features=[]
)

# Add additional deps if not already present
if additional_deps:
existing_lower = {
d.lower().split("[")[0].split(">=")[0].split("==")[0]
for d in normalized
}
for dep in additional_deps:
if dep.lower() not in existing_lower:
normalized.append(dep)

return normalized


def build_sandbox_venv(
filename: str | None,
additional_deps: list[str] | None = None,
) -> tuple[str, str]:
"""Build sandbox venv and install dependencies.

Creates an ephemeral virtual environment using uv with the notebook's
dependencies installed. Used for IPC kernel mode where each notebook
gets its own sandboxed environment.

Args:
filename: Path to notebook file for reading dependencies.
additional_deps: Extra dependencies to add (e.g., IPC_KERNEL_DEPS).

Returns:
Tuple of (sandbox_dir, venv_python_path).

Raises:
RuntimeError: If dependency installation fails.
"""
uv_bin = find_uv_bin()

# Create temp directory for sandbox venv
sandbox_dir = tempfile.mkdtemp(prefix="marimo-sandbox-")
venv_path = os.path.join(sandbox_dir, "venv")

# Phase 1: Create venv
echo(f"Creating sandbox environment: {muted(venv_path)}", err=True)
subprocess.run(
[uv_bin, "venv", "--seed", venv_path],
check=True,
capture_output=True,
)

# Get venv Python path
if sys.platform == "win32":
venv_python = os.path.join(venv_path, "Scripts", "python.exe")
else:
venv_python = os.path.join(venv_path, "bin", "python")

# Phase 2: Install dependencies
requirements = get_sandbox_requirements(filename, additional_deps)
echo("Installing sandbox dependencies...", err=True)

# Separate editable installs from regular requirements
# Editable installs look like "-e /path/to/package"
editable_reqs = [r for r in requirements if r.startswith("-e ")]
regular_reqs = [r for r in requirements if not r.startswith("-e ")]

# Install editable packages directly (not via requirements file)
for editable in editable_reqs:
# Extract path from "-e /path/to/package"
editable_path = editable[3:].strip()
result = subprocess.run(
[
uv_bin,
"pip",
"install",
"--python",
venv_python,
"-e",
editable_path,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
echo(
f"Warning: Editable install failed: {result.stderr}",
err=True,
)

# Install regular packages via requirements file
if regular_reqs:
req_file = os.path.join(sandbox_dir, "requirements.txt")
with open(req_file, "w", encoding="utf-8") as f:
f.write("\n".join(regular_reqs))

result = subprocess.run(
[
uv_bin,
"pip",
"install",
"--python",
venv_python,
"-r",
req_file,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
# Clean up on failure
cleanup_sandbox_dir(sandbox_dir)
raise RuntimeError(
f"Failed to install sandbox dependencies: {result.stderr}"
)

return sandbox_dir, venv_python


def cleanup_sandbox_dir(sandbox_dir: str | None) -> None:
"""Clean up sandbox directory.

Safely removes the sandbox directory and all its contents.
Silently ignores errors (e.g., if directory doesn't exist).

Args:
sandbox_dir: Path to sandbox directory, or None (no-op).
"""
if sandbox_dir:
try:
shutil.rmtree(sandbox_dir)
except OSError:
pass
1 change: 1 addition & 0 deletions marimo/_dependencies/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ class DependencyManager:
redshift_connector = Dependency("redshift_connector")
mcp = Dependency("mcp")
pydantic_ai = Dependency("pydantic_ai")
zmq = Dependency("zmq") # pyzmq for IPC kernels in sandbox home mode

# Version requirements to properly support the new superfences introduced in
# pymdown#2470
Expand Down
Loading
Loading