code-mower 0.5.0b5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_mower/__init__.py +3 -0
- code_mower/adapters/__init__.py +39 -0
- code_mower/adapters/_base.py +148 -0
- code_mower/adapters/cursor_bugbot.py +97 -0
- code_mower/adapters/gitar.py +111 -0
- code_mower/adapters/greptile.py +191 -0
- code_mower/adapters/qodo.py +140 -0
- code_mower/antigravity_cli_audit_pr.py +241 -0
- code_mower/audit_handoff_log.py +345 -0
- code_mower/audit_labeler_lib.py +311 -0
- code_mower/audit_progress.py +203 -0
- code_mower/blind_review_artifacts.py +562 -0
- code_mower/blind_review_coordinator.py +276 -0
- code_mower/bootstrap.py +524 -0
- code_mower/builder_experiment.py +539 -0
- code_mower/calibration/__init__.py +181 -0
- code_mower/calibration/arms.py +267 -0
- code_mower/calibration/auto_discovery.py +348 -0
- code_mower/calibration/commands.py +192 -0
- code_mower/calibration/context_inputs.py +199 -0
- code_mower/calibration/corpus.py +91 -0
- code_mower/calibration/evidence.py +20 -0
- code_mower/calibration/evidence_report.py +360 -0
- code_mower/calibration/identity.py +27 -0
- code_mower/calibration/metrics.py +18 -0
- code_mower/calibration/overlap.py +103 -0
- code_mower/calibration/planning.py +309 -0
- code_mower/calibration/policy.py +207 -0
- code_mower/calibration/results.py +315 -0
- code_mower/calibration/run_results.py +147 -0
- code_mower/calibration/run_status.py +64 -0
- code_mower/calibration/runner.py +360 -0
- code_mower/calibration/truth.py +188 -0
- code_mower/calibration/value_report.py +142 -0
- code_mower/checks.py +402 -0
- code_mower/claude_audit_pr.py +1126 -0
- code_mower/claude_cli_bounce.py +303 -0
- code_mower/claude_cli_environment.py +73 -0
- code_mower/clear_stale.py +374 -0
- code_mower/cli.py +537 -0
- code_mower/cloud.py +674 -0
- code_mower/cloud_client/__init__.py +173 -0
- code_mower/cloud_client/bundle.py +155 -0
- code_mower/cloud_client/doctor.py +206 -0
- code_mower/cloud_client/dogfood.py +78 -0
- code_mower/cloud_client/endpoints.py +114 -0
- code_mower/cloud_client/errors.py +7 -0
- code_mower/cloud_client/events.py +278 -0
- code_mower/cloud_client/export.py +272 -0
- code_mower/cloud_client/git_metadata.py +46 -0
- code_mower/cloud_client/manifest.py +39 -0
- code_mower/cloud_client/operations.py +448 -0
- code_mower/cloud_client/reports.py +45 -0
- code_mower/cloud_client/setup.py +205 -0
- code_mower/cloud_client/upload.py +97 -0
- code_mower/code_mower_calibration.py +598 -0
- code_mower/code_mower_context_packs.py +591 -0
- code_mower/code_mower_merge.py +227 -0
- code_mower/code_mower_telemetry.py +561 -0
- code_mower/coderabbit_cli_audit_pr.py +526 -0
- code_mower/codex_audit_env_preflight.py +220 -0
- code_mower/codex_audit_pr.py +1738 -0
- code_mower/codex_audit_schema_smoke.py +160 -0
- code_mower/codex_audit_verdict.schema.json +44 -0
- code_mower/config.py +655 -0
- code_mower/doctor.py +161 -0
- code_mower/doctor_checks/__init__.py +104 -0
- code_mower/doctor_checks/cloud.py +129 -0
- code_mower/doctor_checks/common.py +215 -0
- code_mower/doctor_checks/github.py +128 -0
- code_mower/doctor_checks/github_actions.py +11 -0
- code_mower/doctor_checks/github_actions_cost.py +99 -0
- code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
- code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
- code_mower/doctor_checks/github_actions_failure_models.py +47 -0
- code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
- code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
- code_mower/doctor_checks/github_actions_failures.py +103 -0
- code_mower/doctor_checks/github_actions_permissions.py +55 -0
- code_mower/doctor_checks/github_api.py +79 -0
- code_mower/doctor_checks/github_branch.py +56 -0
- code_mower/doctor_checks/github_config.py +25 -0
- code_mower/doctor_checks/github_provider.py +61 -0
- code_mower/doctor_checks/github_repo.py +120 -0
- code_mower/doctor_checks/groups.py +36 -0
- code_mower/doctor_checks/models.py +96 -0
- code_mower/doctor_checks/output.py +86 -0
- code_mower/doctor_checks/presets.py +64 -0
- code_mower/doctor_checks/privacy.py +20 -0
- code_mower/doctor_checks/provider_api_model.py +138 -0
- code_mower/doctor_checks/provider_api_model_openai.py +29 -0
- code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
- code_mower/doctor_checks/provider_env.py +113 -0
- code_mower/doctor_checks/provider_env_required.py +56 -0
- code_mower/doctor_checks/provider_env_tokens.py +100 -0
- code_mower/doctor_checks/provider_local_cli.py +162 -0
- code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
- code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
- code_mower/doctor_checks/provider_probe.py +20 -0
- code_mower/doctor_checks/provider_probe_auth.py +52 -0
- code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
- code_mower/doctor_checks/provider_probe_json.py +45 -0
- code_mower/doctor_checks/provider_probe_remediation.py +39 -0
- code_mower/doctor_checks/providers.py +159 -0
- code_mower/doctor_checks/registry.py +69 -0
- code_mower/doctor_checks/runner.py +188 -0
- code_mower/doctor_checks/runtime.py +89 -0
- code_mower/doctor_checks/runtime_github_auth.py +148 -0
- code_mower/gemini_cli_audit_pr.py +897 -0
- code_mower/hermes_cli_audit_pr.py +436 -0
- code_mower/init.py +888 -0
- code_mower/lane_configs/__init__.py +37 -0
- code_mower/lane_configs/aider.py +32 -0
- code_mower/lane_configs/antigravity_cli.py +35 -0
- code_mower/lane_configs/claude.py +35 -0
- code_mower/lane_configs/codex.py +32 -0
- code_mower/lane_configs/devin.py +33 -0
- code_mower/lane_configs/gemini_cli.py +35 -0
- code_mower/lane_configs/hermes_cli.py +35 -0
- code_mower/lane_configs/local_llm.py +31 -0
- code_mower/local_llm_audit_pr.py +1364 -0
- code_mower/local_llm_bakeoff.py +458 -0
- code_mower/local_llm_calibration.py +441 -0
- code_mower/local_llm_profiles.py +66 -0
- code_mower/migration.py +508 -0
- code_mower/migration_install.py +292 -0
- code_mower/migration_mirror.py +392 -0
- code_mower/migration_readiness.py +237 -0
- code_mower/migration_rehearsal.py +718 -0
- code_mower/next_steps.py +441 -0
- code_mower/package.py +673 -0
- code_mower/package_content.py +444 -0
- code_mower/package_manifest.py +452 -0
- code_mower/package_paths.py +53 -0
- code_mower/package_rendering.py +90 -0
- code_mower/package_static.py +585 -0
- code_mower/prompts.py +267 -0
- code_mower/provider_registry.py +469 -0
- code_mower/provider_runners/__init__.py +60 -0
- code_mower/provider_runners/comments.py +31 -0
- code_mower/provider_runners/git.py +46 -0
- code_mower/provider_runners/github_auth.py +61 -0
- code_mower/provider_runners/github_pr.py +120 -0
- code_mower/provider_runners/process.py +58 -0
- code_mower/provider_runners/repo_paths.py +23 -0
- code_mower/provider_runners/text_schema.py +41 -0
- code_mower/provider_runners/verdict_artifacts.py +103 -0
- code_mower/provider_runners/workspace.py +57 -0
- code_mower/release_readiness.py +549 -0
- code_mower/reviewer_metrics.py +389 -0
- code_mower/saas_reviewer_labeler.py +809 -0
- code_mower/secrets.py +89 -0
- code_mower/templates/builder-experiment.example.json +55 -0
- code_mower/templates/calibration-corpus.example.json +129 -0
- code_mower/templates/calibration-corpus.json +129 -0
- code_mower/templates/code-mower.example.yml +423 -0
- code_mower/templates/context-packs.example.json +150 -0
- code_mower/templates/lane_prompts/base-audit.md +22 -0
- code_mower/templates/lane_prompts/calibration-policy.md +21 -0
- code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
- code_mower/templates/lane_prompts/docs-design.md +12 -0
- code_mower/templates/lane_prompts/generic-programming.md +21 -0
- code_mower/templates/lane_prompts/operability.md +22 -0
- code_mower/templates/lane_prompts/package-runtime.md +12 -0
- code_mower/templates/lane_prompts/security-threat-model.md +22 -0
- code_mower/templates/product-support/code_mower +216 -0
- code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
- code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
- code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
- code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
- code_mower/templates/product-support/safe_gh_comment.py +96 -0
- code_mower/templates/providers.yml +454 -0
- code_mower/templates/reviewer-spend.example.json +28 -0
- code_mower/templates/reviewer-value-report.example.md +20 -0
- code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
- code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
- code_mower/trailer_comment_labeler.py +207 -0
- code_mower/versioning.py +32 -0
- code_mower-0.5.0b5.dist-info/METADATA +302 -0
- code_mower-0.5.0b5.dist-info/RECORD +185 -0
- code_mower-0.5.0b5.dist-info/WHEEL +5 -0
- code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
- code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
- code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
- code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Provider token environment status helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .common import SUPPORTED_TOKEN_FILE_ENV_NAMES, as_sequence, code_mower_secrets
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProviderTokenStatus:
|
|
15
|
+
token_env: tuple[str, ...]
|
|
16
|
+
token_env_any: tuple[tuple[str, ...], ...]
|
|
17
|
+
token_file_env: tuple[str, ...]
|
|
18
|
+
missing: tuple[str, ...]
|
|
19
|
+
missing_any: tuple[tuple[str, ...], ...]
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def declares_tokens(self) -> bool:
|
|
23
|
+
return bool(self.token_env or self.token_env_any)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def all_present(self) -> bool:
|
|
27
|
+
return not self.missing and not self.missing_any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _token_file_value(name: str, path_text: str) -> str:
|
|
31
|
+
if name not in SUPPORTED_TOKEN_FILE_ENV_NAMES:
|
|
32
|
+
return ""
|
|
33
|
+
try:
|
|
34
|
+
result = code_mower_secrets.read_secret_file(
|
|
35
|
+
Path(path_text),
|
|
36
|
+
supported_env_names=SUPPORTED_TOKEN_FILE_ENV_NAMES,
|
|
37
|
+
)
|
|
38
|
+
except OSError:
|
|
39
|
+
return ""
|
|
40
|
+
return result.value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _token_is_present(name: str) -> bool:
|
|
44
|
+
if os.environ.get(name):
|
|
45
|
+
return True
|
|
46
|
+
path_text = os.environ.get(f"{name}_FILE", "").strip()
|
|
47
|
+
if not path_text:
|
|
48
|
+
return False
|
|
49
|
+
return bool(_token_file_value(name, path_text))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _token_file_env_names(
|
|
53
|
+
token_env: tuple[str, ...],
|
|
54
|
+
token_env_any: tuple[tuple[str, ...], ...],
|
|
55
|
+
) -> tuple[str, ...]:
|
|
56
|
+
names = [
|
|
57
|
+
f"{name}_FILE"
|
|
58
|
+
for name in token_env
|
|
59
|
+
if name in SUPPORTED_TOKEN_FILE_ENV_NAMES and os.environ.get(f"{name}_FILE")
|
|
60
|
+
]
|
|
61
|
+
names.extend(
|
|
62
|
+
f"{name}_FILE"
|
|
63
|
+
for group in token_env_any
|
|
64
|
+
for name in group
|
|
65
|
+
if name in SUPPORTED_TOKEN_FILE_ENV_NAMES and os.environ.get(f"{name}_FILE")
|
|
66
|
+
)
|
|
67
|
+
return tuple(sorted(set(names)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def provider_token_status(lane: Mapping[str, Any]) -> ProviderTokenStatus:
|
|
71
|
+
token_env = tuple(str(item) for item in as_sequence(lane.get("token_env", [])))
|
|
72
|
+
token_env_any = tuple(
|
|
73
|
+
tuple(str(item) for item in as_sequence(group))
|
|
74
|
+
for group in as_sequence(lane.get("token_env_any", []))
|
|
75
|
+
)
|
|
76
|
+
review_hygiene = lane.get("review_hygiene", {})
|
|
77
|
+
if (
|
|
78
|
+
not token_env
|
|
79
|
+
and isinstance(review_hygiene, Mapping)
|
|
80
|
+
and review_hygiene.get("token_env")
|
|
81
|
+
):
|
|
82
|
+
token_env = (str(review_hygiene["token_env"]),)
|
|
83
|
+
|
|
84
|
+
token_file_env = _token_file_env_names(token_env, token_env_any)
|
|
85
|
+
missing = tuple(name for name in token_env if not _token_is_present(name))
|
|
86
|
+
missing_any = tuple(
|
|
87
|
+
group
|
|
88
|
+
for group in token_env_any
|
|
89
|
+
if group and not any(_token_is_present(name) for name in group)
|
|
90
|
+
)
|
|
91
|
+
return ProviderTokenStatus(
|
|
92
|
+
token_env=token_env,
|
|
93
|
+
token_env_any=token_env_any,
|
|
94
|
+
token_file_env=token_file_env,
|
|
95
|
+
missing=missing,
|
|
96
|
+
missing_any=missing_any,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["ProviderTokenStatus", "provider_token_status"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Provider local CLI discovery and auth smoke probes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .common import (
|
|
10
|
+
DoctorCheck,
|
|
11
|
+
STATUS_PASS,
|
|
12
|
+
STATUS_SKIP,
|
|
13
|
+
STATUS_WARN,
|
|
14
|
+
local_cli_remediation,
|
|
15
|
+
)
|
|
16
|
+
from .provider_local_cli_commands import (
|
|
17
|
+
candidate_local_cli_commands,
|
|
18
|
+
local_cli_command,
|
|
19
|
+
resolved_local_cli_command,
|
|
20
|
+
)
|
|
21
|
+
from .provider_local_cli_probe_config import (
|
|
22
|
+
local_cli_probe_args,
|
|
23
|
+
local_cli_probe_env,
|
|
24
|
+
local_cli_probe_timeout,
|
|
25
|
+
)
|
|
26
|
+
from .provider_probe import evaluate_json_probe, local_cli_probe_remediation
|
|
27
|
+
from .privacy import auth_probe_output_detail
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_local_cli(lane_id: str, lane: Mapping[str, Any]) -> DoctorCheck:
|
|
31
|
+
provider_config = lane.get("provider_config", {})
|
|
32
|
+
commands = candidate_local_cli_commands(lane)
|
|
33
|
+
detail: dict[str, Any] = {"commands": commands}
|
|
34
|
+
if isinstance(provider_config, Mapping) and provider_config.get("command"):
|
|
35
|
+
detail["command"] = str(provider_config["command"])
|
|
36
|
+
if isinstance(provider_config, Mapping) and provider_config.get("command_env"):
|
|
37
|
+
detail["command_env"] = str(provider_config["command_env"])
|
|
38
|
+
if isinstance(provider_config, Mapping) and provider_config.get("protocol"):
|
|
39
|
+
detail["protocol"] = str(provider_config["protocol"])
|
|
40
|
+
for command in commands:
|
|
41
|
+
resolved = shutil.which(command)
|
|
42
|
+
if resolved:
|
|
43
|
+
detail.update({"command": command, "path": resolved})
|
|
44
|
+
return DoctorCheck(
|
|
45
|
+
name="runtime.local_cli",
|
|
46
|
+
status=STATUS_PASS,
|
|
47
|
+
lane=lane_id,
|
|
48
|
+
message=f"{command} found",
|
|
49
|
+
detail=detail,
|
|
50
|
+
)
|
|
51
|
+
return DoctorCheck(
|
|
52
|
+
name="runtime.local_cli",
|
|
53
|
+
status=STATUS_WARN,
|
|
54
|
+
lane=lane_id,
|
|
55
|
+
message=f"none of the candidate commands were found: {', '.join(commands)}",
|
|
56
|
+
detail=detail,
|
|
57
|
+
remediation=local_cli_remediation(
|
|
58
|
+
commands,
|
|
59
|
+
str(detail.get("command_env", "")),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_local_cli_probe(
|
|
65
|
+
lane_id: str,
|
|
66
|
+
lane: Mapping[str, Any],
|
|
67
|
+
*,
|
|
68
|
+
probe_runtime: bool,
|
|
69
|
+
http_timeout: int,
|
|
70
|
+
) -> DoctorCheck:
|
|
71
|
+
if not probe_runtime:
|
|
72
|
+
return DoctorCheck(
|
|
73
|
+
name="runtime.local_cli.probe",
|
|
74
|
+
status=STATUS_SKIP,
|
|
75
|
+
lane=lane_id,
|
|
76
|
+
message="local CLI probing skipped; pass --probe-runtime to run a harmless version/help command",
|
|
77
|
+
)
|
|
78
|
+
resolved_pair = resolved_local_cli_command(lane)
|
|
79
|
+
if resolved_pair is None:
|
|
80
|
+
command = local_cli_command(lane)
|
|
81
|
+
return DoctorCheck(
|
|
82
|
+
name="runtime.local_cli.probe",
|
|
83
|
+
status=STATUS_WARN,
|
|
84
|
+
lane=lane_id,
|
|
85
|
+
message=f"{command} was not found, so runtime probe could not run",
|
|
86
|
+
detail={"command": command},
|
|
87
|
+
remediation=local_cli_remediation([command]),
|
|
88
|
+
)
|
|
89
|
+
command, resolved = resolved_pair
|
|
90
|
+
probe_args = local_cli_probe_args(lane, command)
|
|
91
|
+
timeout_seconds = local_cli_probe_timeout(lane, http_timeout)
|
|
92
|
+
child_env, env_detail = local_cli_probe_env(lane)
|
|
93
|
+
try:
|
|
94
|
+
completed = subprocess.run(
|
|
95
|
+
[resolved, *probe_args],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
check=False,
|
|
99
|
+
timeout=timeout_seconds,
|
|
100
|
+
env=child_env,
|
|
101
|
+
)
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
103
|
+
return DoctorCheck(
|
|
104
|
+
name="runtime.local_cli.probe",
|
|
105
|
+
status=STATUS_WARN,
|
|
106
|
+
lane=lane_id,
|
|
107
|
+
message=f"{command} probe failed: {exc}",
|
|
108
|
+
detail={
|
|
109
|
+
"command": command,
|
|
110
|
+
"path": resolved,
|
|
111
|
+
"args": list(probe_args),
|
|
112
|
+
"timeout_seconds": timeout_seconds,
|
|
113
|
+
**env_detail,
|
|
114
|
+
},
|
|
115
|
+
remediation=local_cli_probe_remediation(command, probe_args, lane),
|
|
116
|
+
)
|
|
117
|
+
output = (completed.stdout or completed.stderr or "").strip()
|
|
118
|
+
provider_config = lane.get("provider_config", {})
|
|
119
|
+
json_detail: dict[str, Any] = {}
|
|
120
|
+
json_message = ""
|
|
121
|
+
if isinstance(provider_config, Mapping) and provider_config.get("doctor_probe_expect_json"):
|
|
122
|
+
status, json_message, json_detail = evaluate_json_probe(
|
|
123
|
+
provider_config,
|
|
124
|
+
output,
|
|
125
|
+
returncode=completed.returncode,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
status = STATUS_PASS if completed.returncode == 0 else STATUS_WARN
|
|
129
|
+
return DoctorCheck(
|
|
130
|
+
name="runtime.local_cli.probe",
|
|
131
|
+
status=status,
|
|
132
|
+
lane=lane_id,
|
|
133
|
+
message=(
|
|
134
|
+
f"{command} {json_message}"
|
|
135
|
+
if json_message
|
|
136
|
+
else (
|
|
137
|
+
f"{command} probe succeeded"
|
|
138
|
+
if status == STATUS_PASS
|
|
139
|
+
else f"{command} probe exited {completed.returncode}"
|
|
140
|
+
)
|
|
141
|
+
),
|
|
142
|
+
detail={
|
|
143
|
+
"command": command,
|
|
144
|
+
"path": resolved,
|
|
145
|
+
"args": list(probe_args),
|
|
146
|
+
"timeout_seconds": timeout_seconds,
|
|
147
|
+
"returncode": completed.returncode,
|
|
148
|
+
**env_detail,
|
|
149
|
+
**json_detail,
|
|
150
|
+
**auth_probe_output_detail(output),
|
|
151
|
+
},
|
|
152
|
+
remediation=(
|
|
153
|
+
None
|
|
154
|
+
if status == STATUS_PASS
|
|
155
|
+
else local_cli_probe_remediation(
|
|
156
|
+
command,
|
|
157
|
+
probe_args,
|
|
158
|
+
lane,
|
|
159
|
+
auth_error_detected=bool(json_detail.get("auth_error_detected")),
|
|
160
|
+
)
|
|
161
|
+
),
|
|
162
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Provider local CLI command discovery helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .common import as_sequence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def candidate_local_cli_commands(lane: Mapping[str, Any]) -> list[str]:
|
|
13
|
+
provider_config = lane.get("provider_config", {})
|
|
14
|
+
commands: list[str] = []
|
|
15
|
+
if isinstance(provider_config, Mapping):
|
|
16
|
+
command_env = str(provider_config.get("command_env", ""))
|
|
17
|
+
if command_env and os.environ.get(command_env):
|
|
18
|
+
commands.append(str(os.environ[command_env]))
|
|
19
|
+
if provider_config.get("command"):
|
|
20
|
+
commands.append(str(provider_config["command"]))
|
|
21
|
+
for command in as_sequence(provider_config.get("alternate_commands", [])):
|
|
22
|
+
if command:
|
|
23
|
+
commands.append(str(command))
|
|
24
|
+
if not commands:
|
|
25
|
+
provider = str(lane.get("provider", ""))
|
|
26
|
+
commands.append(provider.replace("_", "-"))
|
|
27
|
+
deduped: list[str] = []
|
|
28
|
+
for command in commands:
|
|
29
|
+
if command and command not in deduped:
|
|
30
|
+
deduped.append(command)
|
|
31
|
+
return deduped
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def local_cli_command(lane: Mapping[str, Any]) -> str:
|
|
35
|
+
candidates = candidate_local_cli_commands(lane)
|
|
36
|
+
if candidates:
|
|
37
|
+
return candidates[0]
|
|
38
|
+
provider = str(lane.get("provider", "")).replace("_", "-")
|
|
39
|
+
return provider or "unknown"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolved_local_cli_command(lane: Mapping[str, Any]) -> tuple[str, str] | None:
|
|
43
|
+
for command in candidate_local_cli_commands(lane):
|
|
44
|
+
resolved = shutil.which(command)
|
|
45
|
+
if resolved:
|
|
46
|
+
return command, resolved
|
|
47
|
+
return None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Provider local CLI probe argument, timeout, and environment helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .common import (
|
|
10
|
+
SUPPORTED_TOKEN_FILE_ENV_NAMES,
|
|
11
|
+
as_sequence,
|
|
12
|
+
code_mower_secrets,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def local_cli_probe_args(lane: Mapping[str, Any], command: str) -> tuple[str, ...]:
|
|
17
|
+
provider_config = lane.get("provider_config", {})
|
|
18
|
+
if isinstance(provider_config, Mapping):
|
|
19
|
+
raw_probe = provider_config.get("doctor_probe_args")
|
|
20
|
+
if isinstance(raw_probe, (list, tuple)) and raw_probe:
|
|
21
|
+
return tuple(str(part) for part in raw_probe)
|
|
22
|
+
provider = str(lane.get("provider") or "")
|
|
23
|
+
if provider in {"gemini", "antigravity", "coderabbit", "claude", "codex"}:
|
|
24
|
+
return ("--version",)
|
|
25
|
+
return ("--help",)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def local_cli_probe_timeout(lane: Mapping[str, Any], default_timeout: int) -> int:
|
|
29
|
+
provider_config = lane.get("provider_config", {})
|
|
30
|
+
if not isinstance(provider_config, Mapping):
|
|
31
|
+
return default_timeout
|
|
32
|
+
raw_timeout = provider_config.get("doctor_probe_timeout_seconds")
|
|
33
|
+
if raw_timeout is None:
|
|
34
|
+
return default_timeout
|
|
35
|
+
try:
|
|
36
|
+
return max(1, int(raw_timeout))
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return default_timeout
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local_cli_probe_env(lane: Mapping[str, Any]) -> tuple[dict[str, str], dict[str, Any]]:
|
|
42
|
+
child_env = os.environ.copy()
|
|
43
|
+
forwarded: list[str] = []
|
|
44
|
+
|
|
45
|
+
token_names: list[str] = []
|
|
46
|
+
for item in as_sequence(lane.get("token_env", [])):
|
|
47
|
+
token_names.append(str(item))
|
|
48
|
+
for group in as_sequence(lane.get("token_env_any", [])):
|
|
49
|
+
for item in as_sequence(group):
|
|
50
|
+
token_names.append(str(item))
|
|
51
|
+
|
|
52
|
+
for name in token_names:
|
|
53
|
+
if name not in SUPPORTED_TOKEN_FILE_ENV_NAMES or child_env.get(name):
|
|
54
|
+
continue
|
|
55
|
+
path_text = child_env.get(f"{name}_FILE", "").strip()
|
|
56
|
+
if not path_text:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
result = code_mower_secrets.read_secret_file(
|
|
60
|
+
Path(path_text),
|
|
61
|
+
supported_env_names=SUPPORTED_TOKEN_FILE_ENV_NAMES,
|
|
62
|
+
)
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
if not result.ok:
|
|
66
|
+
continue
|
|
67
|
+
child_env[name] = result.value
|
|
68
|
+
forwarded.append(f"{name}_FILE")
|
|
69
|
+
|
|
70
|
+
return child_env, {"token_file_env_forwarded": sorted(set(forwarded))}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Provider auth smoke-probe compatibility facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .provider_probe_auth import (
|
|
6
|
+
probe_auth_error_detail,
|
|
7
|
+
probe_error_value_is_clean,
|
|
8
|
+
)
|
|
9
|
+
from .provider_probe_evaluation import evaluate_json_probe
|
|
10
|
+
from .provider_probe_json import json_field, parse_probe_json
|
|
11
|
+
from .provider_probe_remediation import local_cli_probe_remediation
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"evaluate_json_probe",
|
|
15
|
+
"json_field",
|
|
16
|
+
"local_cli_probe_remediation",
|
|
17
|
+
"parse_probe_json",
|
|
18
|
+
"probe_auth_error_detail",
|
|
19
|
+
"probe_error_value_is_clean",
|
|
20
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Provider probe auth-error classification helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .provider_probe_json import json_field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def probe_error_value_is_clean(value: Any) -> bool:
|
|
11
|
+
return value is None or value is False or value == "" or value == 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def probe_auth_error_detail(
|
|
15
|
+
payload: Mapping[str, Any],
|
|
16
|
+
error_fields: tuple[str, ...],
|
|
17
|
+
auth_status_fields: tuple[str, ...],
|
|
18
|
+
output: str,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
detail: dict[str, Any] = {}
|
|
21
|
+
for field in auth_status_fields:
|
|
22
|
+
status_value = json_field(payload, field)
|
|
23
|
+
if status_value is None:
|
|
24
|
+
continue
|
|
25
|
+
status_text = str(status_value).strip()
|
|
26
|
+
if status_text in {"401", "403"}:
|
|
27
|
+
detail["auth_status_code"] = status_text
|
|
28
|
+
detail["auth_status_field"] = field
|
|
29
|
+
detail["auth_error_detected"] = True
|
|
30
|
+
break
|
|
31
|
+
|
|
32
|
+
lowered_output = output.lower()
|
|
33
|
+
auth_markers = (
|
|
34
|
+
"invalid authentication",
|
|
35
|
+
"authentication credentials",
|
|
36
|
+
"unauthorized",
|
|
37
|
+
"forbidden",
|
|
38
|
+
"not authenticated",
|
|
39
|
+
"auth token",
|
|
40
|
+
"api key",
|
|
41
|
+
"oauth",
|
|
42
|
+
)
|
|
43
|
+
has_dirty_error = any(
|
|
44
|
+
not probe_error_value_is_clean(json_field(payload, field))
|
|
45
|
+
for field in error_fields
|
|
46
|
+
)
|
|
47
|
+
if has_dirty_error and any(marker in lowered_output for marker in auth_markers):
|
|
48
|
+
detail["auth_error_detected"] = True
|
|
49
|
+
return detail
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ("probe_auth_error_detail", "probe_error_value_is_clean")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Provider auth smoke-probe evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .common import STATUS_PASS, STATUS_WARN, as_sequence
|
|
8
|
+
from .provider_probe_auth import probe_auth_error_detail, probe_error_value_is_clean
|
|
9
|
+
from .provider_probe_json import json_field, parse_probe_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def evaluate_json_probe(
|
|
13
|
+
provider_config: Mapping[str, Any],
|
|
14
|
+
output: str,
|
|
15
|
+
*,
|
|
16
|
+
returncode: int,
|
|
17
|
+
) -> tuple[str, str, dict[str, Any]]:
|
|
18
|
+
payload, parse_detail = parse_probe_json(output)
|
|
19
|
+
detail: dict[str, Any] = dict(parse_detail)
|
|
20
|
+
if payload is None:
|
|
21
|
+
if returncode != 0:
|
|
22
|
+
return (
|
|
23
|
+
STATUS_WARN,
|
|
24
|
+
f"probe exited {returncode} without parseable JSON",
|
|
25
|
+
detail,
|
|
26
|
+
)
|
|
27
|
+
return (
|
|
28
|
+
STATUS_WARN,
|
|
29
|
+
"probe output was not parseable JSON",
|
|
30
|
+
detail,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
error_fields = tuple(
|
|
34
|
+
str(field)
|
|
35
|
+
for field in as_sequence(provider_config.get("doctor_probe_error_fields", []))
|
|
36
|
+
if str(field).strip()
|
|
37
|
+
)
|
|
38
|
+
dirty_errors = []
|
|
39
|
+
for field in error_fields:
|
|
40
|
+
value = json_field(payload, field)
|
|
41
|
+
if not probe_error_value_is_clean(value):
|
|
42
|
+
dirty_errors.append(field)
|
|
43
|
+
detail["error_fields"] = list(error_fields)
|
|
44
|
+
detail["dirty_error_fields"] = dirty_errors
|
|
45
|
+
auth_status_fields = tuple(
|
|
46
|
+
str(field)
|
|
47
|
+
for field in as_sequence(
|
|
48
|
+
provider_config.get(
|
|
49
|
+
"doctor_probe_auth_status_fields",
|
|
50
|
+
("api_error_status",),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
if str(field).strip()
|
|
54
|
+
)
|
|
55
|
+
detail["auth_status_fields"] = list(auth_status_fields)
|
|
56
|
+
detail.update(
|
|
57
|
+
probe_auth_error_detail(
|
|
58
|
+
payload,
|
|
59
|
+
error_fields,
|
|
60
|
+
auth_status_fields,
|
|
61
|
+
output,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
field = str(provider_config.get("doctor_probe_expect_json_field", "")).strip()
|
|
66
|
+
expected = provider_config.get("doctor_probe_expect_json_value")
|
|
67
|
+
response_matches = True
|
|
68
|
+
if field:
|
|
69
|
+
value = json_field(payload, field)
|
|
70
|
+
response_matches = expected is None or str(value).strip() == str(expected).strip()
|
|
71
|
+
detail.update(
|
|
72
|
+
{
|
|
73
|
+
"response_field": field,
|
|
74
|
+
"response_present": value is not None,
|
|
75
|
+
"response_length": len(str(value)) if value is not None else 0,
|
|
76
|
+
"response_matches_expected": response_matches,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if returncode != 0:
|
|
81
|
+
if detail.get("auth_error_detected"):
|
|
82
|
+
return (
|
|
83
|
+
STATUS_WARN,
|
|
84
|
+
"probe reported provider authentication failure",
|
|
85
|
+
detail,
|
|
86
|
+
)
|
|
87
|
+
return STATUS_WARN, f"probe exited {returncode}", detail
|
|
88
|
+
if dirty_errors:
|
|
89
|
+
if detail.get("auth_error_detected"):
|
|
90
|
+
return (
|
|
91
|
+
STATUS_WARN,
|
|
92
|
+
"probe reported provider authentication failure",
|
|
93
|
+
detail,
|
|
94
|
+
)
|
|
95
|
+
return (
|
|
96
|
+
STATUS_WARN,
|
|
97
|
+
"probe reported provider/API error fields",
|
|
98
|
+
detail,
|
|
99
|
+
)
|
|
100
|
+
if not response_matches:
|
|
101
|
+
return (
|
|
102
|
+
STATUS_WARN,
|
|
103
|
+
"probe response did not match expected sentinel",
|
|
104
|
+
detail,
|
|
105
|
+
)
|
|
106
|
+
return STATUS_PASS, "auth smoke probe succeeded", detail
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ("evaluate_json_probe",)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Provider probe JSON parsing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_probe_json(output: str) -> tuple[Mapping[str, Any] | None, dict[str, Any]]:
|
|
10
|
+
text = output.strip()
|
|
11
|
+
if not text:
|
|
12
|
+
return None, {"json_parsed": False, "json_error": "empty_output"}
|
|
13
|
+
|
|
14
|
+
candidates = [text]
|
|
15
|
+
start = text.find("{")
|
|
16
|
+
end = text.rfind("}")
|
|
17
|
+
if start >= 0 and end > start:
|
|
18
|
+
candidates.append(text[start : end + 1])
|
|
19
|
+
|
|
20
|
+
last_error = "json"
|
|
21
|
+
for candidate in candidates:
|
|
22
|
+
try:
|
|
23
|
+
payload = json.loads(candidate)
|
|
24
|
+
except json.JSONDecodeError as exc:
|
|
25
|
+
last_error = str(exc)
|
|
26
|
+
continue
|
|
27
|
+
if isinstance(payload, Mapping):
|
|
28
|
+
return payload, {
|
|
29
|
+
"json_parsed": True,
|
|
30
|
+
"json_extracted": candidate != text,
|
|
31
|
+
}
|
|
32
|
+
return None, {"json_parsed": False, "json_error": "not_object"}
|
|
33
|
+
return None, {"json_parsed": False, "json_error": last_error}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def json_field(payload: Mapping[str, Any], dotted_field: str) -> Any:
|
|
37
|
+
value: Any = payload
|
|
38
|
+
for part in dotted_field.split("."):
|
|
39
|
+
if not isinstance(value, Mapping) or part not in value:
|
|
40
|
+
return None
|
|
41
|
+
value = value[part]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ("json_field", "parse_probe_json")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Provider auth smoke-probe remediation text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def local_cli_probe_remediation(
|
|
9
|
+
command: str,
|
|
10
|
+
probe_args: tuple[str, ...],
|
|
11
|
+
lane: Mapping[str, Any],
|
|
12
|
+
*,
|
|
13
|
+
auth_error_detected: bool = False,
|
|
14
|
+
) -> str:
|
|
15
|
+
provider = str(lane.get("provider") or "").strip()
|
|
16
|
+
if auth_error_detected and provider == "claude":
|
|
17
|
+
return (
|
|
18
|
+
"Run `claude auth status`, then run "
|
|
19
|
+
"`claude -p \"Reply with exactly: ok\" --output-format json`. "
|
|
20
|
+
"If status says logged in but the prompt returns 401, run "
|
|
21
|
+
"`code-mower claude-bounce --json` first to distinguish inherited "
|
|
22
|
+
"environment issues from stale local credentials. If clean_env also "
|
|
23
|
+
"returns 401, refresh Claude Code OAuth by removing stale local "
|
|
24
|
+
"Claude credentials and running `claude` to log in again. For "
|
|
25
|
+
"automation, prefer a provider/API-key credential path over "
|
|
26
|
+
"interactive Claude.ai OAuth."
|
|
27
|
+
)
|
|
28
|
+
if auth_error_detected:
|
|
29
|
+
return (
|
|
30
|
+
f"Run `{command} {' '.join(probe_args)}` manually, refresh provider "
|
|
31
|
+
"auth credentials or API keys, then rerun doctor --probe-runtime."
|
|
32
|
+
)
|
|
33
|
+
return (
|
|
34
|
+
f"Run `{command} {' '.join(probe_args)}` manually, fix CLI "
|
|
35
|
+
"installation or auth, then rerun doctor --probe-runtime."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ("local_cli_probe_remediation",)
|