Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 30 additions & 14 deletions inkbox_claude/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unreadable-file fallback is incomplete: read_text() can raise UnicodeDecodeError, and malformed assignments such as an empty key raise while updating os.environ outside this try. Because lower-priority files are now always read, an unrelated malformed file can crash startup even when a higher-priority config is complete. Please skip decode and malformed-assignment failures per candidate and add regression coverage for both cases.

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("'"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because every later file is merged per key, omission in a higher-priority config no longer restores its documented default. A complete explicit config can silently inherit stale optional values from the state-dir file, and a partial cwd config can be combined with credentials or identity from a different install, producing a configuration no single file defines. Please keep the explicit file authoritative and only fall through from cwd when it has zero bridge-owned keys (or otherwise select one Inkbox config atomically) rather than merging independent configs per key.



def run_foreground() -> int:
Expand Down
41 changes: 41 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down