2026-04-16 18:50:50 -05:00
|
|
|
"""Tests for ``headroom.paths`` -- canonical filesystem contract."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from headroom import paths
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
|
|
|
|
|
"""Ensure every HEADROOM_* env var this module touches is unset."""
|
|
|
|
|
|
|
|
|
|
for name in (
|
|
|
|
|
paths.HEADROOM_CONFIG_DIR_ENV,
|
|
|
|
|
paths.HEADROOM_WORKSPACE_DIR_ENV,
|
|
|
|
|
paths.HEADROOM_SAVINGS_PATH_ENV,
|
|
|
|
|
paths.HEADROOM_TOIN_PATH_ENV,
|
|
|
|
|
paths.HEADROOM_SUBSCRIPTION_STATE_PATH_ENV,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
return monkeypatch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def fake_home(clean_env: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
|
|
|
"""Redirect ``Path.home()`` to ``tmp_path`` for isolation."""
|
|
|
|
|
|
|
|
|
|
clean_env.setenv("HOME", str(tmp_path))
|
|
|
|
|
# On Windows ``Path.home()`` reads ``USERPROFILE`` first, then ``HOME``.
|
|
|
|
|
clean_env.setenv("USERPROFILE", str(tmp_path))
|
|
|
|
|
return tmp_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Canonical roots
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.workspace_dir() == fake_home / ".headroom"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_dir_env_override(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
override = tmp_path / "alt_ws"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(override))
|
|
|
|
|
assert paths.workspace_dir() == override
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_dir_tilde_expansion(fake_home: Path, clean_env: pytest.MonkeyPatch) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, "~/custom")
|
|
|
|
|
assert paths.workspace_dir() == fake_home / "custom"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_dir_blank_env_is_ignored(fake_home: Path, clean_env: pytest.MonkeyPatch) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, " ")
|
|
|
|
|
assert paths.workspace_dir() == fake_home / ".headroom"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.config_dir() == fake_home / ".headroom" / "config"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_dir_follows_workspace_when_only_workspace_set(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
override = tmp_path / "alt_ws"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(override))
|
|
|
|
|
assert paths.config_dir() == override / "config"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_dir_explicit_env_overrides_workspace(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(tmp_path / "ws"))
|
|
|
|
|
config_override = tmp_path / "cfg"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_CONFIG_DIR_ENV, str(config_override))
|
|
|
|
|
assert paths.config_dir() == config_override
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_dir_tilde_expansion(fake_home: Path, clean_env: pytest.MonkeyPatch) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_CONFIG_DIR_ENV, "~/cfg")
|
|
|
|
|
assert paths.config_dir() == fake_home / "cfg"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Ensure-* side effects
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_workspace_dir_getter_no_mkdir(fake_home: Path) -> None:
|
|
|
|
|
target = paths.workspace_dir()
|
|
|
|
|
assert not target.exists()
|
|
|
|
|
# Calling again must still not create it.
|
|
|
|
|
assert not paths.workspace_dir().exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_dir_getter_no_mkdir(fake_home: Path) -> None:
|
|
|
|
|
target = paths.config_dir()
|
|
|
|
|
assert not target.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_workspace_dir_creates(fake_home: Path) -> None:
|
|
|
|
|
result = paths.ensure_workspace_dir()
|
|
|
|
|
assert result.is_dir()
|
|
|
|
|
assert result == fake_home / ".headroom"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_config_dir_creates(fake_home: Path) -> None:
|
|
|
|
|
result = paths.ensure_config_dir()
|
|
|
|
|
assert result.is_dir()
|
|
|
|
|
assert result == fake_home / ".headroom" / "config"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_per_resource_getters_no_mkdir(fake_home: Path) -> None:
|
|
|
|
|
# None of these should trigger directory creation.
|
|
|
|
|
paths.savings_path()
|
|
|
|
|
paths.toin_path()
|
|
|
|
|
paths.subscription_state_path()
|
|
|
|
|
paths.memory_db_path()
|
|
|
|
|
paths.native_memory_dir()
|
|
|
|
|
paths.license_cache_path()
|
|
|
|
|
paths.session_stats_path()
|
|
|
|
|
paths.sync_state_path()
|
|
|
|
|
paths.bridge_state_path()
|
|
|
|
|
paths.log_dir()
|
|
|
|
|
paths.proxy_log_path()
|
|
|
|
|
paths.debug_400_dir()
|
|
|
|
|
paths.bin_dir()
|
|
|
|
|
paths.deploy_root()
|
|
|
|
|
paths.beacon_lock_path(8787)
|
|
|
|
|
paths.models_config_path()
|
|
|
|
|
paths.plugin_config_dir("example")
|
|
|
|
|
paths.plugin_workspace_dir("example")
|
|
|
|
|
assert not (fake_home / ".headroom").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Per-resource precedence matrix
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RESOURCES_WITH_LEGACY_ENV = [
|
|
|
|
|
pytest.param(
|
|
|
|
|
"savings_path",
|
|
|
|
|
paths.HEADROOM_SAVINGS_PATH_ENV,
|
|
|
|
|
"proxy_savings.json",
|
|
|
|
|
id="savings",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
"toin_path",
|
|
|
|
|
paths.HEADROOM_TOIN_PATH_ENV,
|
|
|
|
|
"toin.json",
|
|
|
|
|
id="toin",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
"subscription_state_path",
|
|
|
|
|
paths.HEADROOM_SUBSCRIPTION_STATE_PATH_ENV,
|
|
|
|
|
"subscription_state.json",
|
|
|
|
|
id="subscription",
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_default_under_home(
|
|
|
|
|
fake_home: Path, fn_name: str, env_var: str, filename: str
|
|
|
|
|
) -> None:
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn() == fake_home / ".headroom" / filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_derived_from_workspace_env(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn() == ws / filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_legacy_env_wins_over_workspace(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
legacy = tmp_path / "legacy_custom.json"
|
|
|
|
|
clean_env.setenv(env_var, str(legacy))
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
# Legacy per-resource env var wins. Backward compatibility is preserved.
|
|
|
|
|
assert fn() == legacy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_explicit_arg_wins(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
legacy = tmp_path / "legacy_custom.json"
|
|
|
|
|
clean_env.setenv(env_var, str(legacy))
|
|
|
|
|
explicit = tmp_path / "explicit.json"
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn(str(explicit)) == explicit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_legacy_env_tilde_expansion(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(env_var, "~/foo.json")
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn() == fake_home / "foo.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_explicit_none_falls_through(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn(None) == fake_home / ".headroom" / filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,env_var,filename", RESOURCES_WITH_LEGACY_ENV)
|
|
|
|
|
def test_resource_explicit_empty_string_falls_through(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
env_var: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
assert fn("") == fake_home / ".headroom" / filename
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Resources without a legacy env var (derived-only from canonical roots)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_memory_db_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.memory_db_path() == fake_home / ".headroom" / "memory.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_memory_db_path_follows_workspace_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(tmp_path / "ws"))
|
|
|
|
|
assert paths.memory_db_path() == tmp_path / "ws" / "memory.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_native_memory_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.native_memory_dir() == fake_home / ".headroom" / "memories"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_license_cache_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.license_cache_path() == fake_home / ".headroom" / "license_cache.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_session_stats_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.session_stats_path() == fake_home / ".headroom" / "session_stats.jsonl"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 19:59:30 -05:00
|
|
|
def test_sync_state_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.sync_state_path() == fake_home / ".headroom" / "sync_state.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bridge_state_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.bridge_state_path() == fake_home / ".headroom" / "bridge_state.json"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 18:50:50 -05:00
|
|
|
def test_log_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.log_dir() == fake_home / ".headroom" / "logs"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 19:59:30 -05:00
|
|
|
def test_proxy_log_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.proxy_log_path() == fake_home / ".headroom" / "logs" / "proxy.log"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 18:50:50 -05:00
|
|
|
def test_debug_400_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.debug_400_dir() == fake_home / ".headroom" / "logs" / "debug_400"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bin_dir_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.bin_dir() == fake_home / ".headroom" / "bin"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deploy_root_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.deploy_root() == fake_home / ".headroom" / "deploy"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_beacon_lock_path_includes_port(fake_home: Path) -> None:
|
|
|
|
|
assert paths.beacon_lock_path(8787) == fake_home / ".headroom" / ".beacon_lock_8787"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 19:59:30 -05:00
|
|
|
# Every derived-only helper must also honor HEADROOM_WORKSPACE_DIR overrides so
|
|
|
|
|
# that a single env var relocates the whole workspace bucket. One row per
|
|
|
|
|
# helper, each asserting the override flows through end-to-end.
|
|
|
|
|
DERIVED_WORKSPACE_HELPERS = [
|
|
|
|
|
pytest.param("native_memory_dir", "memories", id="native_memory_dir"),
|
|
|
|
|
pytest.param("license_cache_path", "license_cache.json", id="license_cache_path"),
|
|
|
|
|
pytest.param("session_stats_path", "session_stats.jsonl", id="session_stats_path"),
|
|
|
|
|
pytest.param("sync_state_path", "sync_state.json", id="sync_state_path"),
|
|
|
|
|
pytest.param("bridge_state_path", "bridge_state.json", id="bridge_state_path"),
|
|
|
|
|
pytest.param("log_dir", "logs", id="log_dir"),
|
|
|
|
|
pytest.param("debug_400_dir", "logs/debug_400", id="debug_400_dir"),
|
|
|
|
|
pytest.param("bin_dir", "bin", id="bin_dir"),
|
|
|
|
|
pytest.param("deploy_root", "deploy", id="deploy_root"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("fn_name,rel", DERIVED_WORKSPACE_HELPERS)
|
|
|
|
|
def test_derived_helper_follows_workspace_env(
|
|
|
|
|
fake_home: Path,
|
|
|
|
|
clean_env: pytest.MonkeyPatch,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
fn_name: str,
|
|
|
|
|
rel: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
fn = getattr(paths, fn_name)
|
|
|
|
|
expected = ws
|
|
|
|
|
for part in rel.split("/"):
|
|
|
|
|
expected = expected / part
|
|
|
|
|
assert fn() == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_proxy_log_path_follows_workspace_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
assert paths.proxy_log_path() == ws / "logs" / "proxy.log"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_beacon_lock_path_follows_workspace_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "state"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
assert paths.beacon_lock_path(9999) == ws / ".beacon_lock_9999"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_workspace_dir_follows_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
ws = tmp_path / "ws"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(ws))
|
|
|
|
|
result = paths.ensure_workspace_dir()
|
|
|
|
|
assert result == ws
|
|
|
|
|
assert result.is_dir()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_config_dir_follows_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
cfg = tmp_path / "cfg"
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_CONFIG_DIR_ENV, str(cfg))
|
|
|
|
|
result = paths.ensure_config_dir()
|
|
|
|
|
assert result == cfg
|
|
|
|
|
assert result.is_dir()
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 18:50:50 -05:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Config bucket
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_models_config_path_default(fake_home: Path) -> None:
|
|
|
|
|
assert paths.models_config_path() == fake_home / ".headroom" / "config" / "models.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_models_config_path_follows_config_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_CONFIG_DIR_ENV, str(tmp_path / "cfg"))
|
|
|
|
|
assert paths.models_config_path() == tmp_path / "cfg" / "models.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_models_config_path_follows_workspace_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(tmp_path / "ws"))
|
|
|
|
|
assert paths.models_config_path() == tmp_path / "ws" / "config" / "models.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Plugin namespace isolation
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plugin_config_dir_namespaced(fake_home: Path) -> None:
|
|
|
|
|
a = paths.plugin_config_dir("alpha")
|
|
|
|
|
b = paths.plugin_config_dir("beta")
|
|
|
|
|
assert a != b
|
|
|
|
|
assert a == fake_home / ".headroom" / "config" / "plugins" / "alpha"
|
|
|
|
|
assert b == fake_home / ".headroom" / "config" / "plugins" / "beta"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plugin_workspace_dir_namespaced(fake_home: Path) -> None:
|
|
|
|
|
a = paths.plugin_workspace_dir("alpha")
|
|
|
|
|
b = paths.plugin_workspace_dir("beta")
|
|
|
|
|
assert a != b
|
|
|
|
|
assert a == fake_home / ".headroom" / "plugins" / "alpha"
|
|
|
|
|
assert b == fake_home / ".headroom" / "plugins" / "beta"
|
|
|
|
|
|
|
|
|
|
|
fix(paths): reject '.', '..', and NUL as plugin names (#2132)
Fixes #2131.
## Description
`plugin_config_dir` / `plugin_workspace_dir` rejected `/` and `\` in the
plugin name but accepted `.` and `..`. Since the returned path is
`<root> / "plugins" / name`, `plugin_config_dir("..")` resolved to the
whole config root and `plugin_workspace_dir("..")` to the whole
workspace root: savings ledger, memory DB, license cache, logs, and
every other plugin's state. That defeated the sandbox the helper was
written to enforce.
Both callers are folded onto a shared `_validate_plugin_name` that
rejects the empty string, both path separators, `.`, `..`, and NUL.
Closes #2131
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/paths.py`: added `_validate_plugin_name` and shared it
across `plugin_config_dir` and `plugin_workspace_dir`.
- `tests/test_paths.py`: expanded invalid-name coverage for `.`, `..`,
and NUL and added a sandbox-escape regression test.
- `CHANGELOG.md`: noted the path traversal fix.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_paths.py -q
........................................................................... [100%]
79 passed in 0.14s
$ uv run ruff check headroom/paths.py tests/test_paths.py
All checks passed!
$ uv run ruff format --check headroom/paths.py tests/test_paths.py
2 files already formatted
```
## Real Behavior Proof
- Environment: macOS 25.4 (Darwin arm64), Python 3.12.13, `uv 0.11.28`,
branch `fix/plugin-path-traversal`.
- Exact command / steps: set `HEADROOM_CONFIG_DIR=/tmp/hc` and
`HEADROOM_WORKSPACE_DIR=/tmp/hw`, then call `plugin_config_dir("..")`,
`plugin_config_dir(".")`, and `plugin_config_dir("legit-plugin")`.
- Observed result: before the patch, `plugin_config_dir("..")` resolved
to `/private/tmp/hc`, escaping the plugin sandbox. After the patch,
`plugin_config_dir("..")` and `plugin_config_dir(".")` raise
`ValueError`; a normal plugin name resolves under
`/private/tmp/hc/plugins/legit-plugin`.
- Not tested: Windows behavior and plugin-registry integration. The
added check is a pure string validation at the path-helper layer.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A.
## Additional Notes
- Documentation is not updated because this is a helper-level sandbox
fix rather than a user-facing behavior change; the changelog entry
captures it.
- `mypy headroom` was not run in the author's workflow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 16:56:19 -07:00
|
|
|
@pytest.mark.parametrize("bad_name", ["", "foo/bar", "foo\\bar", "..", ".", "\x00"])
|
2026-04-16 18:50:50 -05:00
|
|
|
def test_plugin_dirs_reject_bad_names(fake_home: Path, bad_name: str) -> None:
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
paths.plugin_config_dir(bad_name)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
paths.plugin_workspace_dir(bad_name)
|
|
|
|
|
|
|
|
|
|
|
fix(paths): reject '.', '..', and NUL as plugin names (#2132)
Fixes #2131.
## Description
`plugin_config_dir` / `plugin_workspace_dir` rejected `/` and `\` in the
plugin name but accepted `.` and `..`. Since the returned path is
`<root> / "plugins" / name`, `plugin_config_dir("..")` resolved to the
whole config root and `plugin_workspace_dir("..")` to the whole
workspace root: savings ledger, memory DB, license cache, logs, and
every other plugin's state. That defeated the sandbox the helper was
written to enforce.
Both callers are folded onto a shared `_validate_plugin_name` that
rejects the empty string, both path separators, `.`, `..`, and NUL.
Closes #2131
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/paths.py`: added `_validate_plugin_name` and shared it
across `plugin_config_dir` and `plugin_workspace_dir`.
- `tests/test_paths.py`: expanded invalid-name coverage for `.`, `..`,
and NUL and added a sandbox-escape regression test.
- `CHANGELOG.md`: noted the path traversal fix.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_paths.py -q
........................................................................... [100%]
79 passed in 0.14s
$ uv run ruff check headroom/paths.py tests/test_paths.py
All checks passed!
$ uv run ruff format --check headroom/paths.py tests/test_paths.py
2 files already formatted
```
## Real Behavior Proof
- Environment: macOS 25.4 (Darwin arm64), Python 3.12.13, `uv 0.11.28`,
branch `fix/plugin-path-traversal`.
- Exact command / steps: set `HEADROOM_CONFIG_DIR=/tmp/hc` and
`HEADROOM_WORKSPACE_DIR=/tmp/hw`, then call `plugin_config_dir("..")`,
`plugin_config_dir(".")`, and `plugin_config_dir("legit-plugin")`.
- Observed result: before the patch, `plugin_config_dir("..")` resolved
to `/private/tmp/hc`, escaping the plugin sandbox. After the patch,
`plugin_config_dir("..")` and `plugin_config_dir(".")` raise
`ValueError`; a normal plugin name resolves under
`/private/tmp/hc/plugins/legit-plugin`.
- Not tested: Windows behavior and plugin-registry integration. The
added check is a pure string validation at the path-helper layer.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A.
## Additional Notes
- Documentation is not updated because this is a helper-level sandbox
fix rather than a user-facing behavior change; the changelog entry
captures it.
- `mypy headroom` was not run in the author's workflow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 16:56:19 -07:00
|
|
|
def test_plugin_dirs_reject_traversal_escapes_sandbox(fake_home: Path) -> None:
|
|
|
|
|
"""A plugin name of ``..`` must not resolve outside ``plugins/``.
|
|
|
|
|
|
|
|
|
|
Without this guard, ``plugin_config_dir("..") == config_dir()`` — a plugin
|
|
|
|
|
can read/write the entire config root (models catalog, other plugins'
|
|
|
|
|
settings), and ``plugin_workspace_dir("..") == workspace_dir()`` exposes
|
|
|
|
|
the license cache, savings ledger, memory DB, and logs.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
for name in ("..", "."):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
paths.plugin_config_dir(name)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
paths.plugin_workspace_dir(name)
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 19:59:30 -05:00
|
|
|
def test_plugin_config_dir_follows_config_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_CONFIG_DIR_ENV, str(tmp_path / "cfg"))
|
|
|
|
|
assert paths.plugin_config_dir("alpha") == tmp_path / "cfg" / "plugins" / "alpha"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_plugin_workspace_dir_follows_workspace_env(
|
|
|
|
|
fake_home: Path, clean_env: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
clean_env.setenv(paths.HEADROOM_WORKSPACE_DIR_ENV, str(tmp_path / "ws"))
|
|
|
|
|
assert paths.plugin_workspace_dir("alpha") == tmp_path / "ws" / "plugins" / "alpha"
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 18:50:50 -05:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Returns Path, not str
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_helpers_return_path(fake_home: Path) -> None:
|
|
|
|
|
assert isinstance(paths.workspace_dir(), Path)
|
|
|
|
|
assert isinstance(paths.config_dir(), Path)
|
|
|
|
|
assert isinstance(paths.savings_path(), Path)
|
|
|
|
|
assert isinstance(paths.toin_path(), Path)
|
|
|
|
|
assert isinstance(paths.subscription_state_path(), Path)
|
|
|
|
|
assert isinstance(paths.memory_db_path(), Path)
|
|
|
|
|
assert isinstance(paths.models_config_path(), Path)
|
|
|
|
|
assert isinstance(paths.plugin_config_dir("x"), Path)
|
|
|
|
|
assert isinstance(paths.plugin_workspace_dir("x"), Path)
|