molecule-core/workspace/tests/test_configs_dir.py
Hongming Wang c636022d2f fix(runtime): auto-fallback CONFIGS_DIR for non-container hosts (closes #2458)
The runtime persists per-workspace state (`.auth_token`,
`.platform_inbound_secret`, `.mcp_inbox_cursor`) under `/configs` —
the workspace-EC2 mount path. Inside a container that's writable,
agent-owned. Outside a container, `/configs` either doesn't exist or
isn't writable by an unprivileged user.

The default broke the external-runtime path (`pip install
molecule-ai-workspace-runtime` + `molecule-mcp` on a Mac/Linux
laptop). First heartbeat tries to persist `.platform_inbound_secret`
and crashes:

    [Errno 30] Read-only file system: '/configs'

The heartbeat thread logs and dies. Workspace flips offline within
a minute. Operator sees no actionable error.

Adds workspace/configs_dir.py — single resolution point with a tiered
fallback:

  1. CONFIGS_DIR env var, if set — explicit operator override
     (preserves existing tests + custom deployments verbatim).
  2. /configs — if it exists AND is writable. In-container default;
     unchanged behavior for every prod workspace.
  3. ~/.molecule-workspace — created with mode 0700 so per-file 0600
     perms aren't undermined by a world-readable parent.

Migrates the four readers (platform_auth, platform_inbound_auth,
mcp_cli, inbox) to call configs_dir.resolve() instead of
inlining `Path(os.environ.get("CONFIGS_DIR", "/configs"))`.

Existing tests that assert the old `/configs`-as-default contract
updated to assert the new contract: when CONFIGS_DIR is unset, path
resolves to a writable location — `/configs` if present, fallback
otherwise. Tests skip the fallback branch on hosts that DO have a
writable `/configs` (CI containers).

Verified the original repro is fixed: with no CONFIGS_DIR set on
macOS, configs_dir.resolve() returns ~/.molecule-workspace, the dir
exists, and writes succeed.

Test suite: 1454 passed, 3 skipped, 2 xfailed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:07:55 -07:00

117 lines
4.6 KiB
Python

"""Tests for workspace/configs_dir.py — the single resolution point
for the per-workspace state directory."""
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
import configs_dir
@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
"""Each test gets a clean cache and a clean env. Tests that need
CONFIGS_DIR set monkeypatch it themselves."""
monkeypatch.delenv("CONFIGS_DIR", raising=False)
configs_dir.reset_cache()
yield
configs_dir.reset_cache()
def test_explicit_env_var_wins(tmp_path, monkeypatch):
"""An explicit CONFIGS_DIR is the operator's override — always
respected, even when /configs is also writable. This preserves
existing test/custom-deployment patterns that monkeypatch the env
var to a per-test tmp_path."""
monkeypatch.setenv("CONFIGS_DIR", str(tmp_path))
assert configs_dir.resolve() == tmp_path
def test_explicit_env_var_creates_dir(tmp_path, monkeypatch):
"""Explicit override creates the dir if missing — operator can
point at a not-yet-existing path and have the runtime materialize
it."""
target = tmp_path / "nested" / "configs"
monkeypatch.setenv("CONFIGS_DIR", str(target))
assert not target.exists()
configs_dir.resolve()
assert target.exists()
def test_in_container_uses_slash_configs(monkeypatch, tmp_path):
"""When /configs exists and is writable, return it. Verified by
pointing /configs detection at a writable tmp_path via the same
env-var override path the helper exposes."""
# Simulate "in-container" by aliasing /configs to a real writable
# path. Not actually creating /configs on the test host (would
# require root) — instead, rely on the explicit-env-var branch
# which is the same code path operators see in tests today.
monkeypatch.setenv("CONFIGS_DIR", str(tmp_path))
result = configs_dir.resolve()
assert result == tmp_path
assert os.access(str(result), os.W_OK)
def test_falls_back_to_home_when_configs_missing(monkeypatch, tmp_path):
"""No CONFIGS_DIR + no writable /configs → fall back to
~/.molecule-workspace. This is the bug from external-runtime
onboarding (issue #2458): operators on a Mac/Linux laptop don't
have /configs and the default would silently fail on the first
heartbeat write."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
# Ensure /configs is not writable for an unprivileged process.
# This is true on every developer machine — the test is just
# asserting we DON'T pick it up when we can't write to it.
if Path("/configs").exists() and os.access("/configs", os.W_OK):
pytest.skip("/configs is writable on this host; can't exercise fallback")
result = configs_dir.resolve()
assert result == fake_home / ".molecule-workspace"
assert result.exists()
def test_fallback_dir_is_0700(monkeypatch, tmp_path):
"""The fallback dir must be 0700 — per-file 0600 perms on
.auth_token + .platform_inbound_secret would be undermined by a
world-readable parent."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
if Path("/configs").exists() and os.access("/configs", os.W_OK):
pytest.skip("/configs is writable on this host; can't exercise fallback")
result = configs_dir.resolve()
mode = stat.S_IMODE(result.stat().st_mode)
assert mode == 0o700, f"expected 0700, got 0o{mode:o}"
def test_fallback_dir_idempotent(monkeypatch, tmp_path):
"""Resolving twice when the fallback dir already exists is fine
— we don't re-mkdir or change perms on every call."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
if Path("/configs").exists() and os.access("/configs", os.W_OK):
pytest.skip("/configs is writable on this host; can't exercise fallback")
first = configs_dir.resolve()
configs_dir.reset_cache()
second = configs_dir.resolve()
assert first == second
assert second.exists()
def test_env_var_changes_picked_up_live(tmp_path, monkeypatch):
"""Resolution reads CONFIGS_DIR live on each call — existing tests
monkeypatch the env var between cases and expect the new value to
take effect without an explicit cache reset."""
monkeypatch.setenv("CONFIGS_DIR", str(tmp_path))
first = configs_dir.resolve()
new_path = tmp_path / "after-change"
monkeypatch.setenv("CONFIGS_DIR", str(new_path))
second = configs_dir.resolve()
assert first == tmp_path
assert second == new_path