diff --git a/qa/support_vm_preflight.py b/qa/support_vm_preflight.py index 71cce1da..5c0484b6 100644 --- a/qa/support_vm_preflight.py +++ b/qa/support_vm_preflight.py @@ -495,6 +495,16 @@ def inspect_tools( elif codex_required: blockers.append("Codex CLI auth/profile status is not proven") tools["codex_auth"] = codex + + claude_required = "claude" in required_tools + claude = {"available": bool(tools.get("claude", {}).get("available")), "auth_status": "not_required"} + if tools.get("claude", {}).get("available"): + claude["auth_status"] = "not_proven" + claude["auth_probe_command"] = "none" + claude["auth_probe_note"] = "repo-owned preflight does not have an approved Claude auth probe" + if claude_required: + blockers.append("Claude CLI auth/profile status is not proven by repo-owned preflight") + tools["claude_auth"] = claude return tools, blockers, warnings @@ -610,6 +620,71 @@ def build_vm_persona_commands(config: PreflightConfig) -> list[str]: return commands +def lane_auth_ready(agent: str, tools: dict) -> bool: + if agent == "codex": + codex = tools.get("codex_auth", {}) + return codex.get("auth_status") == "proven" and bool(codex.get("mcp_override_supported")) + if agent == "claude": + return tools.get("claude_auth", {}).get("auth_status") == "proven" + return False + + +def readiness_summary( + config: PreflightConfig, + *, + ready: bool, + repo: dict, + tools: dict, + private_art: dict, + repo_files: dict, + required_tools: Sequence[str], +) -> dict: + """Compact path-free readiness object for agent routing.""" + same_sha_ready = ( + bool(config.expected_sha) + and repo.get("expected_sha_match") is True + and repo.get("dirty") is False + and bool(repo.get("origin_main_query", {}).get("ok")) + ) + required_tools_ready = all(bool(tools.get(tool, {}).get("available")) for tool in required_tools) and bool( + tools.get("playwright_node_module", {}).get("available") + ) and bool(tools.get("playwright_chromium", {}).get("available")) + persona_briefs_ready = all( + bool(item.get("present")) for item in repo_files.get("required_files", {}).values() + ) + private_art_ready = config.private_art_mode == "required" and bool(private_art.get("private_root_present")) + artifact_return_ready = bool(config.artifact_return_target.strip()) + provider_auth_ready = lane_auth_ready(config.provider, tools) + player_agent_auth_ready = lane_auth_ready(config.player_agent, tools) + + checks = { + "repo_state": same_sha_ready, + "required_tools": required_tools_ready, + "provider_auth": provider_auth_ready, + "player_agent_auth": player_agent_auth_ready, + "persona_briefs": persona_briefs_ready, + "private_art": private_art_ready, + "artifact_return": artifact_return_ready, + } + return { + "safe_to_run_personas": bool(ready), + "release_verdict": False, + "expected_sha": config.expected_sha, + "repo_head_short": repo.get("head_short") or "", + "same_sha_ready": same_sha_ready, + "provider": config.provider, + "player_agent": config.player_agent, + "provider_auth_ready": provider_auth_ready, + "player_agent_auth_ready": player_agent_auth_ready, + "required_tools_ready": required_tools_ready, + "persona_briefs_ready": persona_briefs_ready, + "private_art_ready": private_art_ready, + "artifact_return_ready": artifact_return_ready, + "mac_handoff_required": True, + "blocking_categories": [name for name, passed in checks.items() if not passed], + } + + def build_report( config: PreflightConfig, *, @@ -660,6 +735,15 @@ def build_report( "tools": tools, "repo_files": repo_files, "private_art": art, + "readiness": readiness_summary( + config, + ready=ready, + repo=repo, + tools=tools, + private_art=art, + repo_files=repo_files, + required_tools=required_tools, + ), "environment": env_snapshot(env or dict(os.environ)), "rri_plan": { "expected_personas": config.personas, @@ -718,6 +802,11 @@ def markdown_report(report: dict) -> str: f"- Origin/main query: `{str(report['repo'].get('origin_main_query', {}).get('ok')).lower()}`", f"- Queried origin/main: `{report['repo'].get('origin_main_query', {}).get('head_short') or 'unknown'}`", "", + "## Readiness", + "", + f"- Safe to run personas: `{str(report.get('readiness', {}).get('safe_to_run_personas')).lower()}`", + f"- Blocking categories: `{','.join(report.get('readiness', {}).get('blocking_categories') or []) or 'none'}`", + "", "## Blockers", "", ] diff --git a/qa/test_support_vm_preflight.py b/qa/test_support_vm_preflight.py index 2b9ecef9..7ef00021 100644 --- a/qa/test_support_vm_preflight.py +++ b/qa/test_support_vm_preflight.py @@ -249,6 +249,29 @@ def test_report_contains_required_sections_with_canonical_personas(self): markdown = preflight.markdown_report(report) self.assertIn("- Required tools: `git,python3,uv,node,npm,npx,jq,curl,lsof,timeout,pkill,pgrep,ps,codex`", markdown) + def test_report_includes_redacted_readiness_summary_for_agent_routing(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + report = preflight.build_report(config, runner=FakeRunner(config.repo), which=fake_which, env={}) + + readiness = report["readiness"] + self.assertTrue(readiness["safe_to_run_personas"]) + self.assertTrue(readiness["same_sha_ready"]) + self.assertTrue(readiness["provider_auth_ready"]) + self.assertTrue(readiness["player_agent_auth_ready"]) + self.assertTrue(readiness["required_tools_ready"]) + self.assertTrue(readiness["persona_briefs_ready"]) + self.assertTrue(readiness["private_art_ready"]) + self.assertTrue(readiness["artifact_return_ready"]) + self.assertTrue(readiness["mac_handoff_required"]) + self.assertFalse(readiness["release_verdict"]) + self.assertEqual(readiness["blocking_categories"], []) + + readiness_blob = json.dumps(readiness) + self.assertNotIn(str(config.repo), readiness_blob) + self.assertNotIn(str(config.art_root), readiness_blob) + self.assertNotIn(config.artifact_return_target, readiness_blob) + def test_report_flags_dirty_repo_and_expected_sha_mismatch(self): with tempfile.TemporaryDirectory() as td: config = make_config(Path(td), expected_sha="1234567") @@ -361,6 +384,19 @@ def which_without_claude(name: str) -> str | None: self.assertIn("WOS_APP_SELECTED_PROVIDER=claude", plan_blob) self.assertIn("WOS_APP_PLAYER_AGENT=claude", plan_blob) + def test_explicit_claude_lane_does_not_report_auth_ready_without_probe(self): + with tempfile.TemporaryDirectory() as td: + config = make_config(Path(td)) + config.provider = "claude" + config.player_agent = "claude" + + report = preflight.build_report(config, runner=FakeRunner(config.repo), which=fake_which, env={}) + + self.assertFalse(report["ready_for_rri"]) + self.assertFalse(report["readiness"]["provider_auth_ready"]) + self.assertFalse(report["readiness"]["player_agent_auth_ready"]) + self.assertIn("Claude CLI auth/profile status is not proven", "\n".join(report["blockers"])) + def test_missing_playwright_chromium_blocks_readiness(self): with tempfile.TemporaryDirectory() as td: config = make_config(Path(td))