mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 09:26:25 +00:00
1517e4dca8
Agent runs are now always worker-owned 'claude -p' subprocesses; tmux survives only for the interactive /login flow. - deleted: worker.capture_agent_output/_pane_tail + the capture loop arm (the empty-/log bug's home), spawn's tmux launch/_claude_command, the tmux resume/kill branches (the silent-send-keys bug's home), tmux.session_name/list_sessions, the CLI attach subcommand, the 'runner' setting - spawn: task is now a hard requirement (headless has no idle REPL) - enforced in spawn (SpawnError) and the API (400); onboarding seeding dropped (-p skips the trust dialog) - resume: single headless path; pre-headless agent rows (no session_id) degrade to the context-re-injection fresh run - settings_gen: permissions allowlist is always emitted - credsync: change-triggered uploads key on .claude/.credentials.json only (claude touches ~/.claude.json every run - keying on it would ping-pong uploads between workers); logins still publish explicitly - cli list: liveness from agent_runs in the DB, not tmux - tests: spawn/kill/resume re-pointed at the fake_launch seam (conftest); integration test now drives API -> worker -> real fake claude subprocess -> events endpoint; README documents the headless model + multi-worker deployment invariants Suite 295 green; frontend unchanged since phase 3.
104 lines
3.7 KiB
Python
104 lines
3.7 KiB
Python
"""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
|