mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 07:56:24 +00:00
feat(control): flag-gated headless runner with cross-worker resume (phase 2)
Wires the phase-1 headless machinery behind runner=headless (default stays tmux; legacy agents, session_id null, keep the tmux paths): - spawn: branches tmux vs headless.launch; extracts _agent_env (shared with resume - a headless resume is a new process needing identity/ credential env); headless spawns require a task (no idle-REPL mode), enforced at spawn and as a 400 in the API - resume: headless path materializes the session archive from the DB onto whichever worker claimed the command, then claude -p --resume; falls back to a fresh session with DB-re-injected context (visible worker event) when no transcript survives anywhere; refuses while a run is live. Undeliverable resumes now raise -> command FAILED, fixing silent input loss on both runners - kill: headless path flags cancel_requested; the owning supervisor SIGTERMs its own child (cross-worker safe) - worker: stable per-container ids, DB-driven run slots (full workers skip claiming spawn/resume/mise_init, leaving them for less-loaded workers), credsync refresh in the main loop - settings_gen: permissions block (defaultMode + allowlist) for headless runs - -p auto-denies anything that would prompt; hooks remain the hard gate - credsync + migration 0009 (runtime_secrets): login publishes the Fernet-encrypted claude credential bundle; every worker materializes it (merge-safe for local trust state); login_submit pinned to the login_start worker via commands.target_worker Suite 270 -> 290 green, including the cross-worker resume linchpin (clean-HOME materialize + --resume against the fake binary).
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Credential distribution through runtime_secrets: the login completes on one worker,
|
||||
every other worker materializes the encrypted bundle from the DB — no shared files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from handler.control import credsync
|
||||
from handler.db import repository as repo
|
||||
from handler.db.engine import get_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secret_env(env, monkeypatch):
|
||||
from handler import config
|
||||
|
||||
monkeypatch.setenv("HANDLER_SECRET_KEY", Fernet.generate_key().decode())
|
||||
config.get_settings.cache_clear()
|
||||
credsync._state.__init__() # fresh sync cursor per test
|
||||
yield env
|
||||
config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def _write_local_credentials(home: Path) -> None:
|
||||
(home / ".claude").mkdir(parents=True, exist_ok=True)
|
||||
(home / ".claude.json").write_text(
|
||||
json.dumps({"oauthAccount": {"email": "op@example.com"}, "theme": "light"})
|
||||
)
|
||||
(home / ".claude" / ".credentials.json").write_text('{"token": "secret-oauth-token"}')
|
||||
|
||||
|
||||
def test_upload_stores_encrypted_bundle(secret_env, tmp_path):
|
||||
_write_local_credentials(tmp_path)
|
||||
assert credsync.upload() is True
|
||||
with get_engine().begin() as conn:
|
||||
row = repo.get_runtime_secret(conn, credsync.SECRET_KEY)
|
||||
assert row is not None
|
||||
# Ciphertext at rest — the raw token must not appear in the DB value.
|
||||
assert "secret-oauth-token" not in row["value_enc"]
|
||||
|
||||
|
||||
def test_refresh_materializes_on_fresh_worker(secret_env, tmp_path, monkeypatch):
|
||||
_write_local_credentials(tmp_path)
|
||||
assert credsync.upload() is True
|
||||
|
||||
other_home = tmp_path / "worker-b"
|
||||
other_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(other_home))
|
||||
credsync._state.__init__() # worker B's process state
|
||||
|
||||
assert credsync.refresh() == "materialized"
|
||||
creds = json.loads((other_home / ".claude" / ".credentials.json").read_text())
|
||||
assert creds["token"] == "secret-oauth-token"
|
||||
data = json.loads((other_home / ".claude.json").read_text())
|
||||
assert data["oauthAccount"]["email"] == "op@example.com"
|
||||
# A second pass is a no-op — nothing changed anywhere.
|
||||
assert credsync.refresh() is None
|
||||
|
||||
|
||||
def test_materialize_merges_claude_json_preserving_local_state(secret_env, tmp_path, monkeypatch):
|
||||
_write_local_credentials(tmp_path)
|
||||
credsync.upload()
|
||||
|
||||
other_home = tmp_path / "worker-c"
|
||||
(other_home / ".claude").mkdir(parents=True)
|
||||
(other_home / ".claude.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"hasCompletedOnboarding": True,
|
||||
"theme": "dark",
|
||||
"projects": {"/projects/p/a": {"hasTrustDialogAccepted": True}},
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv("HOME", str(other_home))
|
||||
credsync._state.__init__()
|
||||
|
||||
assert credsync.refresh() == "materialized"
|
||||
data = json.loads((other_home / ".claude.json").read_text())
|
||||
# Account arrived...
|
||||
assert data["oauthAccount"]["email"] == "op@example.com"
|
||||
# ...but this worker's own onboarding/trust state (claude_config's writes) survived.
|
||||
assert data["theme"] == "dark"
|
||||
assert data["projects"]["/projects/p/a"]["hasTrustDialogAccepted"] is True
|
||||
|
||||
|
||||
def test_refresh_uploads_local_change(secret_env, tmp_path):
|
||||
_write_local_credentials(tmp_path)
|
||||
assert credsync.refresh() == "uploaded" # bootstrap: local creds, empty DB
|
||||
# A token refresh on disk (mtime/size change) re-publishes.
|
||||
os.utime(tmp_path / ".claude" / ".credentials.json", ns=(1, 1))
|
||||
assert credsync.refresh() == "uploaded"
|
||||
|
||||
|
||||
def test_disabled_without_secret_key(env, tmp_path):
|
||||
_write_local_credentials(tmp_path)
|
||||
assert credsync.upload() is False
|
||||
assert credsync.refresh() is None
|
||||
|
||||
|
||||
def test_note_local_write_suppresses_upload(secret_env, tmp_path):
|
||||
_write_local_credentials(tmp_path)
|
||||
credsync.note_local_write()
|
||||
# The deliberate local write (e.g. ensure_onboarded at spawn) is not re-published.
|
||||
assert credsync.refresh() is None
|
||||
Reference in New Issue
Block a user