diff --git a/inkbox_claude/daemon.py b/inkbox_claude/daemon.py index 349f36e..b466ab2 100644 --- a/inkbox_claude/daemon.py +++ b/inkbox_claude/daemon.py @@ -97,11 +97,18 @@ def _read_pid() -> int | None: def _maybe_load_env_file() -> None: - """Fill missing config from a ``.env`` file so the daemon just works. + """Fill missing config from every ``.env`` file we know about. - Loads the first that exists — ``$INKBOX_CLAUDE_ENV_FILE``, then ``./.env``, - then ``~/.inkbox-claude/.env`` (where the installer writes it for a global - install) — and sets any vars not already in the environment (real env wins). + Reads, in priority order, ``$INKBOX_CLAUDE_ENV_FILE``, ``./.env``, then + ``~/.inkbox-claude/.env`` (where the installer writes it for a global + install), and sets any var not already in the environment. Real env beats + every file and an earlier file beats a later one, but a later file still + fills what the earlier ones left out. + + Reading all of them rather than stopping at the first hit is what keeps an + unrelated ``./.env`` -- a project's own secrets, or a stray one in ``$HOME`` + -- from hiding the install's config and making `start` report that + INKBOX_API_KEY is unset when it is right there in the state dir. Returns: None @@ -113,17 +120,26 @@ def _maybe_load_env_file() -> None: candidates.append(Path.cwd() / ".env") candidates.append(_state_dir() / ".env") - path = next((p for p in candidates if p.exists()), None) - if path is None: - return - for line in path.read_text().splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#") or "=" not in stripped: + seen = set() + for path in candidates: + try: + if not path.is_file(): + continue + key_path = path.resolve() + if key_path in seen: # cwd == state dir, or a symlink to it + continue + seen.add(key_path) + lines = path.read_text().splitlines() + except OSError: # unreadable is the same as absent, for our purposes continue - if stripped.startswith("export "): - stripped = stripped[len("export "):] - key, value = stripped.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + if stripped.startswith("export "): + stripped = stripped[len("export "):] + key, value = stripped.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def run_foreground() -> int: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 06fc661..653f998 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -80,6 +80,47 @@ def test_maybe_load_env_file_falls_back_to_state_dir(tmp_path, monkeypatch): assert os.environ["INKBOX_API_KEY"] == "ApiKey_global" +def test_maybe_load_env_file_merges_state_dir_under_an_unrelated_cwd_env(tmp_path, monkeypatch): + # A ./.env that knows nothing about Inkbox (a project's own secrets, or a + # stray one in $HOME) must not hide the install's config in the state dir. + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("INKBOX_API_KEY=ApiKey_global\nINKBOX_IDENTITY=agent\n") + cwd = tmp_path / "elsewhere" + cwd.mkdir() + (cwd / ".env").write_text("SLACK_BOT_TOKEN=xoxb-unrelated\n") + monkeypatch.chdir(cwd) + monkeypatch.delenv("INKBOX_CLAUDE_ENV_FILE", raising=False) + monkeypatch.setenv("INKBOX_CLAUDE_HOME", str(home)) + monkeypatch.delenv("INKBOX_API_KEY", raising=False) + monkeypatch.delenv("INKBOX_IDENTITY", raising=False) + + daemon._maybe_load_env_file() + + assert os.environ["INKBOX_API_KEY"] == "ApiKey_global" + assert os.environ["INKBOX_IDENTITY"] == "agent" + assert os.environ["SLACK_BOT_TOKEN"] == "xoxb-unrelated" # cwd file still applies + + +def test_maybe_load_env_file_earlier_candidate_wins_per_key(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("INKBOX_API_KEY=ApiKey_global\nINKBOX_IDENTITY=global-agent\n") + cwd = tmp_path / "project" + cwd.mkdir() + (cwd / ".env").write_text("INKBOX_IDENTITY=project-agent\n") + monkeypatch.chdir(cwd) + monkeypatch.delenv("INKBOX_CLAUDE_ENV_FILE", raising=False) + monkeypatch.setenv("INKBOX_CLAUDE_HOME", str(home)) + monkeypatch.delenv("INKBOX_API_KEY", raising=False) + monkeypatch.delenv("INKBOX_IDENTITY", raising=False) + + daemon._maybe_load_env_file() + + assert os.environ["INKBOX_IDENTITY"] == "project-agent" # ./.env beats state dir + assert os.environ["INKBOX_API_KEY"] == "ApiKey_global" # ...but does not hide it + + def test_launcher_path_is_a_string(): assert isinstance(daemon._launcher_path(), str)