Skip to content
Closed
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
21 changes: 20 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import logging
import os
import secrets
import stat
import sys
import threading
import time
Expand Down Expand Up @@ -1188,7 +1189,25 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
"expiresAt": expires_at_ms,
}
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
_HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
tmp_path = _HERMES_OAUTH_FILE.with_name(
f"{_HERMES_OAUTH_FILE.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
)
try:
with tmp_path.open("w", encoding="utf-8") as handle:
handle.write(json.dumps(payload, indent=2))
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, _HERMES_OAUTH_FILE)
try:
_HERMES_OAUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
# Best-effort credential-pool insert. Failure here doesn't invalidate
# the file write — pool registration only matters for the rotation
# strategy, not for runtime credential resolution.
Expand Down
53 changes: 53 additions & 0 deletions tests/hermes_cli/test_web_server_oauth_write.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import os

import pytest

from hermes_cli.web_server import _save_anthropic_oauth_creds


class _DummyPool:
def entries(self):
return []

def remove_entry(self, _id):
return None

def add_entry(self, _entry):
return None


@pytest.fixture
def oauth_file(monkeypatch, tmp_path):
target = tmp_path / '.anthropic_oauth.json'
monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target)
monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool())
return target


def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file):
old_umask = os.umask(0o022)
try:
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
finally:
os.umask(old_umask)

assert oauth_file.exists()
mode = oauth_file.stat().st_mode & 0o777
assert mode == 0o600


def test_dashboard_oauth_write_uses_atomic_replace_and_cleans_temp_files(oauth_file, monkeypatch):
replace_calls = []

def flaky_replace(src, dst):
replace_calls.append((src, dst))
raise OSError('simulated replace failure')

monkeypatch.setattr('hermes_cli.web_server.os.replace', flaky_replace)

with pytest.raises(OSError, match='simulated replace failure'):
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)

assert replace_calls, 'helper should attempt atomic os.replace()'
assert not oauth_file.exists()
assert not list(oauth_file.parent.glob(f'{oauth_file.name}.tmp*'))