Skip to content
Merged
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
12 changes: 6 additions & 6 deletions src/hai_agents_common/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ def absolute_share_url(client: Client | AsyncClient, share_path: str) -> str:


def save_api_key(key: str) -> Path:
"""Persist the API key to the global `.env` (chmod 600) and the process env."""
GLOBAL_ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
if not GLOBAL_ENV_PATH.exists():
GLOBAL_ENV_PATH.write_text("", encoding="utf-8")
with contextlib.suppress(OSError):
GLOBAL_ENV_PATH.chmod(0o600)
"""Persist the API key to the global `.env` (file 600, directory 700) and the process env."""
GLOBAL_ENV_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
GLOBAL_ENV_PATH.parent.chmod(0o700) # mkdir leaves an existing directory's mode untouched, so we set it explicitly
# Create owner-only from the start
os.close(os.open(GLOBAL_ENV_PATH, os.O_WRONLY | os.O_CREAT, 0o600))
GLOBAL_ENV_PATH.chmod(0o600) # tighten a file that already existed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Read-only credentials file blocks save

Low Severity

os.open uses O_WRONLY on GLOBAL_ENV_PATH before chmod, so an existing owner-read-only .env raises PermissionError and never gets tightened. save_api_key then cannot rotate the key, including during hai login --force.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ebfe34. Configure here.

set_key(str(GLOBAL_ENV_PATH), API_KEY_VAR, key)
os.environ[API_KEY_VAR] = key
return GLOBAL_ENV_PATH
Expand Down
24 changes: 24 additions & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import stat
import sys

import httpx
import pytest
from typer.testing import CliRunner
Expand Down Expand Up @@ -57,6 +60,27 @@ def test_save_then_clear_roundtrip(monkeypatch):
credentials.resolve_api_key()


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions")
def test_saved_key_is_owner_only(tmp_path, monkeypatch):
directory = tmp_path / "hai"
monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", directory / ".env")
directory.mkdir(mode=0o755)
credentials.GLOBAL_ENV_PATH.write_text("OTHER=1\n")
credentials.GLOBAL_ENV_PATH.chmod(0o644)

credentials.save_api_key("hk-minted")

assert stat.S_IMODE(directory.stat().st_mode) == 0o700
assert stat.S_IMODE(credentials.GLOBAL_ENV_PATH.stat().st_mode) == 0o600

credentials.GLOBAL_ENV_PATH.unlink()
directory.rmdir()
credentials.save_api_key("hk-fresh")

assert stat.S_IMODE(directory.stat().st_mode) == 0o700
assert stat.S_IMODE(credentials.GLOBAL_ENV_PATH.stat().st_mode) == 0o600


def test_absolute_share_url_prepends_base():
class _Wrapper:
def get_base_url(self) -> str:
Expand Down
5 changes: 0 additions & 5 deletions tests/test_polling_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import pytest

from hai_agents.polling import (
MAX_REQUEST_BYTES,
assert_request_under_limit,
is_settled_session_status,
is_terminal_session_status,
Expand Down Expand Up @@ -80,10 +79,6 @@ class Shot(pydantic.BaseModel):
assert_request_under_limit(Shot(png="x" * 200), max_bytes=50)


def test_default_limit_is_five_megabytes() -> None:
assert MAX_REQUEST_BYTES == 5 * 1024 * 1024


def test_wait_times_out_on_wall_clock() -> None:
client = SimpleNamespace(sessions=_AlwaysRunning())
with pytest.raises(TimeoutError, match="did not settle within 0"):
Expand Down
Loading