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
84 changes: 84 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,16 @@ def set_workspace_path(
# Legacy alias — callers / tests still reference the old name.
DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT

# OpenAI Codex OAuth credentials live in profile-local auth.json rather than
# only in config.yaml / .env. Kanban workers run under their assignee's profile,
# so a cloned profile can have a valid model provider but still exit immediately
# if its auth store was not intentionally initialized. Keep this non-secret: the
# preflight checks only config metadata plus auth.json presence/size, never token
# values.
_AUTH_STORE_REQUIRED_PROVIDERS = {
"openai-codex": "OpenAI Codex",
}

# Max bytes to keep in a single worker log file. The dispatcher truncates
# and rotates on spawn if the file is larger than this at spawn time.
DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
Expand Down Expand Up @@ -3107,6 +3117,79 @@ def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
return False


def _worker_profile_dir(profile: str) -> Path:
"""Resolve a kanban assignee profile to its HERMES_HOME directory."""
from hermes_cli.profiles import get_profile_dir

return get_profile_dir(profile)


def _profile_configured_provider(profile_dir: Path) -> Optional[str]:
"""Read the profile's configured model provider without loading secrets."""
config_path = profile_dir / "config.yaml"
if not config_path.is_file():
return None
try:
import yaml

raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
return None
if not isinstance(raw, dict):
return None

provider: Any = None
model_cfg = raw.get("model")
if isinstance(model_cfg, dict):
provider = model_cfg.get("provider")
if not provider:
provider = raw.get("provider")
if not isinstance(provider, str):
return None
provider = provider.strip().lower()
return provider or None


def _preflight_worker_profile_auth(task: Task) -> None:
"""Fail early when a profile-local OAuth auth store is plainly absent.

This intentionally does NOT read or copy auth.json contents. It only checks
the assignee profile's non-secret configured provider and whether an auth
store file exists with nonzero size. The worker still performs the
authoritative credential validation when it starts; this preflight catches
the common cloned-profile footgun before spawning a process that can only
exit immediately.
"""
profile = (task.assignee or "").strip()
if not profile:
return
try:
profile_dir = _worker_profile_dir(profile)
except Exception:
return

provider = _profile_configured_provider(profile_dir)
display_name = _AUTH_STORE_REQUIRED_PROVIDERS.get(provider or "")
if not display_name:
return

auth_path = profile_dir / "auth.json"
try:
has_auth_store = auth_path.is_file() and auth_path.stat().st_size > 0
except OSError:
has_auth_store = False
if has_auth_store:
return

raise RuntimeError(
f"Kanban worker profile {profile!r} is configured for {display_name} "
f"but has no profile-local auth store at {auth_path}. "
f"Authenticate this profile intentionally with `hermes -p {profile} auth` "
f"or `hermes -p {profile} model` before dispatching. Hermes will not "
"copy OAuth credentials from another profile automatically."
)


def dispatch_once(
conn: sqlite3.Connection,
*,
Expand Down Expand Up @@ -3274,6 +3357,7 @@ def _default_spawn(
import subprocess
if not task.assignee:
raise ValueError(f"task {task.id} has no assignee")
_preflight_worker_profile_auth(task)

from hermes_cli.profiles import normalize_profile_name

Expand Down
98 changes: 98 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,104 @@ def boom(task, workspace):
assert kb.get_task(conn, t).claim_lock is None


def test_default_spawn_preflights_missing_profile_oauth_auth_store(
kanban_home, monkeypatch, tmp_path
):
profile_home = kanban_home / "profiles" / "dogfood"
profile_home.mkdir(parents=True)
(profile_home / "config.yaml").write_text(
"model:\n"
" default: gpt-5.5\n"
" provider: openai-codex\n",
encoding="utf-8",
)

popen_called = False

class _FakePopen:
def __init__(self, cmd, **kwargs):
nonlocal popen_called
popen_called = True
self.pid = 4242

monkeypatch.setattr("subprocess.Popen", _FakePopen)

task = kb.Task(
id="t_missing_auth",
title="x",
body=None,
assignee="dogfood",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)

with pytest.raises(RuntimeError, match="dogfood.*OpenAI Codex.*auth"):
kb._default_spawn(task, str(tmp_path / "ws"))

assert not popen_called


def test_default_spawn_allows_profile_with_oauth_auth_store(
kanban_home, monkeypatch, tmp_path
):
profile_home = kanban_home / "profiles" / "dogfood"
profile_home.mkdir(parents=True)
(profile_home / "config.yaml").write_text(
"model:\n"
" default: gpt-5.5\n"
" provider: openai-codex\n",
encoding="utf-8",
)
(profile_home / "auth.json").write_text("{}", encoding="utf-8")

popen_calls = []

class _FakePopen:
def __init__(self, cmd, **kwargs):
popen_calls.append((cmd, kwargs))
stdout = kwargs.get("stdout")
if stdout is not None:
stdout.close()
self.pid = 4242

monkeypatch.setattr("subprocess.Popen", _FakePopen)

task = kb.Task(
id="t_has_auth",
title="x",
body=None,
assignee="dogfood",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
workspace = tmp_path / "ws"
workspace.mkdir()

assert kb._default_spawn(task, str(workspace)) == 4242
assert len(popen_calls) == 1
cmd, kwargs = popen_calls[0]
assert cmd[:3] == ["hermes", "-p", "dogfood"]
assert kwargs["env"]["HERMES_PROFILE"] == "dogfood"


def test_dispatch_reclaims_stale_before_spawning(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x", assignee="alice")
Expand Down