diff --git a/CHANGELOG.md b/CHANGELOG.md index 72879cf..b39e51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ the image workflows publish (plus `latest` from every push to `main`). ## [Unreleased] +### Fixed + +- **Untrusted-workspace wedge on headless runs.** Phase 4's tmux-path deletion also + removed the only call to `claude_config.ensure_onboarded`, so agent working dirs — + every fresh worktree — were never pre-trusted in `~/.claude.json` and headless + `claude -p` runs wedged or refused on the trust dialog with nobody at a TTY. Spawn + and resume now re-seed onboarding + per-directory trust before every launch (resume + included, so a cross-worker resume landing in a container that has never seen the + working dir is covered). 2 regression tests. + ### Added — mobile app feature parity The iOS app (`app/`) catches up with everything the backend and web dashboard gained diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index eaa50d0..a2e158d 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -15,6 +15,7 @@ from ..config import get_settings from ..db import repository as repo from ..db.engine import connection from . import ( + claude_config, claude_gen, credentials, forge, @@ -160,6 +161,11 @@ def spawn( # half also feeds pi-harness agents (their settings.json points at the same dir). # Scoped to the project's owner: shared rows plus theirs, nobody else's. claude_gen.apply(working_dir, visible_to=project.get("owner_user_id")) + # Mark onboarding complete and trust this working dir in ~/.claude.json before + # claude boots: a fresh worktree is a brand-new path, and an untrusted workspace + # wedges/refuses a headless run with nobody at a TTY to accept the dialog. (The + # old tmux launch path did this; it was lost when phase 4 deleted that path.) + claude_config.ensure_onboarded(working_dir) env, harness = _agent_env(project, agent, token, role=role, mise_init=mise_init) # Verify the pinned forge version, if one is configured. Non-fatal: a version drift @@ -286,6 +292,9 @@ def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool working_dir = agent["working_dir"] settings_path = settings_gen.write_settings(working_dir) claude_gen.apply(working_dir, visible_to=project.get("owner_user_id")) + # Cross-worker resume may land in a container whose ~/.claude.json has never seen + # this working dir — re-seed trust exactly as spawn does. + claude_config.ensure_onboarded(working_dir) try: token = None with connection() as conn: diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index 4500b88..629274a 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -181,3 +181,38 @@ def test_resume_refused_while_run_live(env, fake_launch): ok, detail = spawn.resume(agent, "answer") assert ok is False assert "live run" in detail + + +def test_spawn_trusts_working_dir_in_claude_json(env, fake_launch): + """The launch must pre-trust the agent's working dir in ~/.claude.json — an + untrusted workspace wedges a headless run on the trust dialog with nobody at a + TTY (regression: the call was lost when phase 4 deleted the tmux launch path).""" + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + + spawn.spawn("proj", "api", task="build the thing") + + cfg = json.loads((env["tmp"] / ".claude.json").read_text()) + assert cfg["hasCompletedOnboarding"] is True + entry = cfg["projects"][str(root)] + assert entry["hasTrustDialogAccepted"] is True + + +def test_resume_trusts_working_dir_in_claude_json(env, fake_launch): + """Cross-worker resume may run in a container that has never seen this working + dir; resume must re-seed trust exactly as spawn does.""" + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + spawn.spawn("proj", "api", task="do it") + with get_engine().begin() as conn: + agent = repo.get_agent_by_name(conn, "proj", "api") + repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed") + (env["tmp"] / ".claude.json").unlink() # a "fresh container": no config at all + + ok, _ = spawn.resume(agent, "use Postgres") + + assert ok is True + cfg = json.loads((env["tmp"] / ".claude.json").read_text()) + assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True