syncade 0.6.2__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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/auth_check.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""``syncade --auth-check`` — verify configured CREDENTIAL authentication.
|
|
2
|
+
|
|
3
|
+
Probes one representative per DISTINCT CREDENTIAL (provider + resolved mode + key), not
|
|
4
|
+
merely per provider (PR-v2-24): two actors on the same provider but different credentials
|
|
5
|
+
are probed separately, so the check cannot pass on auth it never exercised.
|
|
6
|
+
|
|
7
|
+
Fast pre-flight diagnostic for "did my OAuth token rotate?" /
|
|
8
|
+
"is my provider auth healthy?" — the common operator
|
|
9
|
+
question that :func:`syncade.selfcheck.run_selfcheck` answers via a
|
|
10
|
+
~30s producer-commit smoke. The auth-check is the cheaper sibling:
|
|
11
|
+
~5-10 seconds end-to-end, probes the auth path only (no producer
|
|
12
|
+
round, no worktree, no findings.md).
|
|
13
|
+
|
|
14
|
+
For each unique credential in the config (``[[reviewers]]`` blocks
|
|
15
|
+
plus ``[producer]``, deduped by the credential each actor presents),
|
|
16
|
+
the auth-check runs the provider's documented cheap auth probe:
|
|
17
|
+
|
|
18
|
+
- **anthropic:** ``claude -p "respond with exactly: AUTH OK"
|
|
19
|
+
--output-format json --model <model>``. Success iff
|
|
20
|
+
``is_error == false`` in the JSON envelope AND ``"AUTH OK"`` is
|
|
21
|
+
``in`` the ``.result`` text. The 30-second timeout is generous;
|
|
22
|
+
healthy auth responds in well under 5s.
|
|
23
|
+
- **openai:** ``codex login status`` via the existing
|
|
24
|
+
:meth:`syncade.adapters.openai.CodexAdapter.check_auth` — a
|
|
25
|
+
filesystem check (no network call) the adapter already supports
|
|
26
|
+
for the dispatcher's pre-flight phase.
|
|
27
|
+
|
|
28
|
+
The probe message ``"AUTH OK"`` is matched with ``in`` (not ``==``)
|
|
29
|
+
so a model that wraps the sentinel in additional text — common when
|
|
30
|
+
the model is overly chatty — still passes.
|
|
31
|
+
|
|
32
|
+
This module is NOT part of the review loop. ``syncade <pr-doc>``
|
|
33
|
+
does not call it; selfcheck does not call it; it's a standalone
|
|
34
|
+
operator diagnostic surfaced via ``--auth-check`` and intended to
|
|
35
|
+
be cheap enough to run as a habit before invoking syncade.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
|
|
47
|
+
from syncade.adapters.base import ReviewerInvocationError
|
|
48
|
+
from syncade.config import SyncadeConfig
|
|
49
|
+
from syncade.config_auth import apply_auth_to_env, credential_fingerprint
|
|
50
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
51
|
+
from syncade.process import (
|
|
52
|
+
SubprocessError,
|
|
53
|
+
SubprocessNotFoundError,
|
|
54
|
+
SubprocessTimeoutError,
|
|
55
|
+
run_subprocess,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS: float = 30.0
|
|
59
|
+
"""Per-provider probe timeout. Generous — healthy auth typically
|
|
60
|
+
responds in well under 5s; the 30s ceiling tolerates a slow network
|
|
61
|
+
or a sluggish provider without false-negative failures."""
|
|
62
|
+
|
|
63
|
+
AUTH_CHECK_PROBE_PROMPT: str = "respond with exactly: AUTH OK"
|
|
64
|
+
"""The probe message sent to the anthropic adapter's ``claude -p``
|
|
65
|
+
call. The model is expected to echo ``AUTH OK`` somewhere in its
|
|
66
|
+
response — the ``in`` check (not ``==``) tolerates provider-side
|
|
67
|
+
wrapping (e.g. an over-eager model that prefixes "Sure: AUTH OK")."""
|
|
68
|
+
|
|
69
|
+
AUTH_CHECK_SENTINEL: str = "AUTH OK"
|
|
70
|
+
"""The sentinel string the probe expects in the model's response.
|
|
71
|
+
Module constant so the probe instruction text and the verifier stay
|
|
72
|
+
in lockstep — a refactor that changes the prompt's sentinel but not
|
|
73
|
+
the verifier (or vice versa) would silently break auth-check."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class AuthCheckResult:
|
|
78
|
+
"""Per-provider auth-check outcome.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
provider: The provider name (``"anthropic"`` / ``"openai"``).
|
|
82
|
+
model: The representative model used for the probe — ``None``
|
|
83
|
+
for providers whose probe doesn't pin a model (codex's
|
|
84
|
+
``codex login status`` is filesystem-level and doesn't
|
|
85
|
+
need one).
|
|
86
|
+
ok: ``True`` iff the probe succeeded and the sentinel was
|
|
87
|
+
present.
|
|
88
|
+
duration_seconds: Wall-clock probe duration.
|
|
89
|
+
detail: Operator-facing summary — either the ``"OK (4.2s)"``
|
|
90
|
+
success form or a remediation-bearing failure message
|
|
91
|
+
(e.g. "401 — token expired. Run 'claude' interactively
|
|
92
|
+
to re-authenticate."). Surfaced verbatim in stderr on
|
|
93
|
+
failure paths.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
provider: str
|
|
97
|
+
model: str | None
|
|
98
|
+
ok: bool
|
|
99
|
+
duration_seconds: float
|
|
100
|
+
detail: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Type alias for a provider-probe callable. Tests inject fakes by
|
|
104
|
+
# passing a dict of these to :func:`run_auth_check`'s ``probes``
|
|
105
|
+
# kwarg. Production routing goes through :data:`_DEFAULT_PROBES`.
|
|
106
|
+
ProviderProbe = Callable[..., AuthCheckResult]
|
|
107
|
+
"""``(model, timeout_seconds, env=...) -> AuthCheckResult``.
|
|
108
|
+
|
|
109
|
+
``env`` is the AUTH-APPLIED environment, not the parent's. Without it the probe
|
|
110
|
+
inherited ``ANTHROPIC_API_KEY`` and hit the API even when every anthropic actor declared
|
|
111
|
+
``auth = "subscription"`` -- so the check could BILL the API account it was supposed to
|
|
112
|
+
be keeping the user away from, and green-light a credential the actual run would never
|
|
113
|
+
use. Caught by syncade's own panel."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _probe_anthropic(
|
|
117
|
+
model: str, timeout_seconds: float, env: dict[str, str] | None = None
|
|
118
|
+
) -> AuthCheckResult:
|
|
119
|
+
"""Probe anthropic auth via ``claude -p`` with the sentinel prompt.
|
|
120
|
+
|
|
121
|
+
Build the argv directly rather than going through
|
|
122
|
+
:class:`~syncade.adapters.anthropic.AnthropicAdapter` because the
|
|
123
|
+
auth-check probe doesn't need the adapter's full reviewer-
|
|
124
|
+
invocation argv (``--effort``, ``--permission-mode``,
|
|
125
|
+
``--add-dir``). Keep it minimal: ``claude -p <prompt>
|
|
126
|
+
--output-format json --model <model>``. Same JSON envelope shape
|
|
127
|
+
as the adapter, so the parse logic reuses the same
|
|
128
|
+
``is_error`` / ``.result`` fields.
|
|
129
|
+
|
|
130
|
+
Failure modes:
|
|
131
|
+
|
|
132
|
+
- ``SubprocessNotFoundError`` → claude CLI missing.
|
|
133
|
+
- ``SubprocessTimeoutError`` → probe didn't return in time.
|
|
134
|
+
Typically a hung auth helper or a wedged network.
|
|
135
|
+
- Non-JSON stdout → CLI emitted something other than the
|
|
136
|
+
documented envelope. May indicate a CLI version mismatch.
|
|
137
|
+
- ``is_error: true`` envelope → token issue (401, model
|
|
138
|
+
unavailable, etc.). The envelope's ``.result`` carries the
|
|
139
|
+
provider-readable error; surface it verbatim.
|
|
140
|
+
- Envelope OK but sentinel absent → the model responded but
|
|
141
|
+
didn't follow the instruction. Unlikely auth issue; surfaced
|
|
142
|
+
as a separate failure so the operator can rerun.
|
|
143
|
+
"""
|
|
144
|
+
start = time.monotonic()
|
|
145
|
+
argv = [
|
|
146
|
+
"claude",
|
|
147
|
+
"-p",
|
|
148
|
+
AUTH_CHECK_PROBE_PROMPT,
|
|
149
|
+
"--output-format",
|
|
150
|
+
"json",
|
|
151
|
+
"--model",
|
|
152
|
+
model,
|
|
153
|
+
]
|
|
154
|
+
try:
|
|
155
|
+
result = run_subprocess(argv, timeout=timeout_seconds, env=env)
|
|
156
|
+
except SubprocessNotFoundError:
|
|
157
|
+
return AuthCheckResult(
|
|
158
|
+
provider="anthropic",
|
|
159
|
+
model=model,
|
|
160
|
+
ok=False,
|
|
161
|
+
duration_seconds=time.monotonic() - start,
|
|
162
|
+
detail=(
|
|
163
|
+
"claude binary not found on PATH — install the Anthropic "
|
|
164
|
+
"claude CLI to run auth-check."
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
except SubprocessTimeoutError as exc:
|
|
168
|
+
return AuthCheckResult(
|
|
169
|
+
provider="anthropic",
|
|
170
|
+
model=model,
|
|
171
|
+
ok=False,
|
|
172
|
+
duration_seconds=time.monotonic() - start,
|
|
173
|
+
detail=(
|
|
174
|
+
f"claude auth probe timed out after {exc.timeout:g}s — auth "
|
|
175
|
+
"helper or network may be hung. Run 'claude' interactively "
|
|
176
|
+
"to re-authenticate."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
except SubprocessError as exc:
|
|
180
|
+
return AuthCheckResult(
|
|
181
|
+
provider="anthropic",
|
|
182
|
+
model=model,
|
|
183
|
+
ok=False,
|
|
184
|
+
duration_seconds=time.monotonic() - start,
|
|
185
|
+
detail=(
|
|
186
|
+
f"claude subprocess launch failed: {exc}. Check that the "
|
|
187
|
+
"claude binary is executable and accessible on PATH."
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
duration = time.monotonic() - start
|
|
192
|
+
|
|
193
|
+
envelope: dict | None = None
|
|
194
|
+
try:
|
|
195
|
+
parsed = json.loads(result.stdout)
|
|
196
|
+
if isinstance(parsed, dict):
|
|
197
|
+
envelope = parsed
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
envelope = None
|
|
200
|
+
|
|
201
|
+
if envelope is None:
|
|
202
|
+
stderr_snippet = result.stderr.strip()[:200]
|
|
203
|
+
stdout_snippet = result.stdout.strip()[:200]
|
|
204
|
+
tail = stderr_snippet or stdout_snippet or "(no output)"
|
|
205
|
+
return AuthCheckResult(
|
|
206
|
+
provider="anthropic",
|
|
207
|
+
model=model,
|
|
208
|
+
ok=False,
|
|
209
|
+
duration_seconds=duration,
|
|
210
|
+
detail=(
|
|
211
|
+
f"claude returned non-JSON stdout "
|
|
212
|
+
f"(rc={result.returncode}); output tail: {tail!r}. "
|
|
213
|
+
"May indicate a claude CLI version mismatch."
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
is_error = bool(envelope.get("is_error", False))
|
|
218
|
+
api_error_status = envelope.get("api_error_status")
|
|
219
|
+
envelope_result = envelope.get("result")
|
|
220
|
+
|
|
221
|
+
if is_error or result.returncode != 0:
|
|
222
|
+
msg = (
|
|
223
|
+
envelope_result
|
|
224
|
+
if isinstance(envelope_result, str) and envelope_result
|
|
225
|
+
else f"is_error={is_error}, api_error_status={api_error_status}"
|
|
226
|
+
)
|
|
227
|
+
return AuthCheckResult(
|
|
228
|
+
provider="anthropic",
|
|
229
|
+
model=model,
|
|
230
|
+
ok=False,
|
|
231
|
+
duration_seconds=duration,
|
|
232
|
+
detail=(
|
|
233
|
+
f"FAILED (rc={result.returncode}, "
|
|
234
|
+
f"api_error_status={api_error_status}): {str(msg)[:200]}. "
|
|
235
|
+
"Run 'claude' interactively to re-authenticate."
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if not isinstance(envelope_result, str) or AUTH_CHECK_SENTINEL not in envelope_result:
|
|
240
|
+
excerpt = (
|
|
241
|
+
envelope_result[:120]
|
|
242
|
+
if isinstance(envelope_result, str)
|
|
243
|
+
else str(envelope_result)[:120]
|
|
244
|
+
)
|
|
245
|
+
return AuthCheckResult(
|
|
246
|
+
provider="anthropic",
|
|
247
|
+
model=model,
|
|
248
|
+
ok=False,
|
|
249
|
+
duration_seconds=duration,
|
|
250
|
+
detail=(
|
|
251
|
+
f"claude responded without {AUTH_CHECK_SENTINEL!r} sentinel "
|
|
252
|
+
f"({excerpt!r}). Auth appears OK but the model didn't follow "
|
|
253
|
+
"the instruction; retry once before treating this as a real "
|
|
254
|
+
"failure."
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return AuthCheckResult(
|
|
259
|
+
provider="anthropic",
|
|
260
|
+
model=model,
|
|
261
|
+
ok=True,
|
|
262
|
+
duration_seconds=duration,
|
|
263
|
+
detail=f"OK ({duration:.1f}s)",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _probe_openai(
|
|
268
|
+
model: str, timeout_seconds: float, env: dict[str, str] | None = None
|
|
269
|
+
) -> AuthCheckResult: # noqa: ARG001
|
|
270
|
+
"""Probe codex auth via ``codex login status``.
|
|
271
|
+
|
|
272
|
+
Delegates to
|
|
273
|
+
:meth:`syncade.adapters.openai.CodexAdapter.check_auth`, which
|
|
274
|
+
already implements the documented `codex login status` probe
|
|
275
|
+
with the right error mapping. The model is recorded for
|
|
276
|
+
operator-facing reporting but not used by the probe — codex
|
|
277
|
+
login state is per-token, not per-model.
|
|
278
|
+
|
|
279
|
+
``timeout_seconds`` is part of the :data:`ProviderProbe` protocol
|
|
280
|
+
signature but is intentionally unused: ``CodexAdapter.check_auth``
|
|
281
|
+
has its own internal timeout via ``_CODEX_AUTH_CHECK_TIMEOUT_SECONDS``
|
|
282
|
+
(10s by default). The parameter is kept on the public surface to
|
|
283
|
+
conform to the probe-protocol shape so callers using
|
|
284
|
+
``timeout_seconds=`` as a keyword (including tests) keep working.
|
|
285
|
+
The ``# noqa: ARG001`` annotation marks the intentional non-use for ruff's
|
|
286
|
+
unused-arg linter while keeping the public keyword name stable.
|
|
287
|
+
|
|
288
|
+
The local import sidesteps a circular-import concern (the adapters
|
|
289
|
+
module's tests import from auth_check; importing CodexAdapter at
|
|
290
|
+
module-load would tie this module to the adapters layer).
|
|
291
|
+
"""
|
|
292
|
+
from syncade.adapters.openai import CodexAdapter
|
|
293
|
+
|
|
294
|
+
start = time.monotonic()
|
|
295
|
+
adapter = CodexAdapter()
|
|
296
|
+
try:
|
|
297
|
+
# CodexAdapter.check_auth already raises ReviewerInvocationError
|
|
298
|
+
# on every failure path (binary missing, timeout, non-zero rc
|
|
299
|
+
# from `codex login status`) with operator-facing messages.
|
|
300
|
+
# Take its message verbatim — the codex CLI's own output is
|
|
301
|
+
# exactly what the user needs to see.
|
|
302
|
+
adapter.check_auth()
|
|
303
|
+
except ReviewerInvocationError as exc:
|
|
304
|
+
return AuthCheckResult(
|
|
305
|
+
provider="openai",
|
|
306
|
+
model=model,
|
|
307
|
+
ok=False,
|
|
308
|
+
duration_seconds=time.monotonic() - start,
|
|
309
|
+
detail=str(exc)[:300],
|
|
310
|
+
)
|
|
311
|
+
duration = time.monotonic() - start
|
|
312
|
+
return AuthCheckResult(
|
|
313
|
+
provider="openai",
|
|
314
|
+
model=model,
|
|
315
|
+
ok=True,
|
|
316
|
+
duration_seconds=duration,
|
|
317
|
+
detail=f"OK ({duration:.1f}s)",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
_DEFAULT_PROBES: dict[str, ProviderProbe] = {
|
|
322
|
+
"anthropic": _probe_anthropic,
|
|
323
|
+
"openai": _probe_openai,
|
|
324
|
+
}
|
|
325
|
+
"""Mapping from provider name to its real auth probe. Tests override
|
|
326
|
+
by passing a different dict to :func:`run_auth_check`'s ``probes``
|
|
327
|
+
kwarg; production routes through this default."""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _collect_unique_providers(config: SyncadeConfig) -> list[tuple[str, str]]:
|
|
331
|
+
"""Return ``[(provider, representative_model), ...]`` in config
|
|
332
|
+
order with duplicates removed (first occurrence wins).
|
|
333
|
+
|
|
334
|
+
Iterates reviewers in config order, then the producer, then the three COLD
|
|
335
|
+
actors (judge / drafter / auditor).
|
|
336
|
+
|
|
337
|
+
**The cold actors are why this list exists at all now.** Before PR-v2-23 they
|
|
338
|
+
were hardwired to codex and invisible to config, so this probed reviewers +
|
|
339
|
+
producer and returned exit 0 on a machine that *could not finish a run* — a
|
|
340
|
+
false green, and the most expensive kind, because the user only discovers it
|
|
341
|
+
after both reviewers have run and billed. Now that the judge's provider is
|
|
342
|
+
configurable, an `[synthesizer] provider = "anthropic"` with OpenAI reviewers
|
|
343
|
+
is a perfectly reasonable config that this check MUST cover.
|
|
344
|
+
|
|
345
|
+
Cost is nothing in practice: dedup is by CREDENTIAL (see :func:`_by_credential`),
|
|
346
|
+
so an actor presenting a credential already covered adds no probe. The default
|
|
347
|
+
roster is single-credential, so the cold actors add ZERO probes there. They add
|
|
348
|
+
one only when the operator actually configured a different credential — precisely
|
|
349
|
+
when you want to know it works. (Same provider, DIFFERENT credential still probes:
|
|
350
|
+
that is the point — a second credential is a second thing that can fail to auth.)
|
|
351
|
+
|
|
352
|
+
A "representative" model is sufficient because auth is
|
|
353
|
+
per-token, not per-model; ``claude -p --model claude-opus-4-6``
|
|
354
|
+
and ``claude -p --model claude-sonnet-4-6`` succeed or fail on
|
|
355
|
+
the same OAuth token. Probing one model per credential keeps
|
|
356
|
+
the auth-check fast (the brief's ~5-10s target) without losing
|
|
357
|
+
the diagnostic value.
|
|
358
|
+
"""
|
|
359
|
+
return [(provider, model) for (provider, _cred), (model, _actor) in _by_credential(config)]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _credential_key(actor: object) -> tuple[str, str, tuple[tuple[str, str], ...]]:
|
|
363
|
+
"""(provider, resolved mode, credential fingerprint). The fingerprint READS the actual
|
|
364
|
+
credential from the enforced env -- see credential_fingerprint. Two credentials that
|
|
365
|
+
resolve to the same mode but present different keys are distinct probes."""
|
|
366
|
+
from syncade.auth_preflight import resolve_auth_mode
|
|
367
|
+
|
|
368
|
+
provider = str(getattr(actor, "provider", ""))
|
|
369
|
+
mode = resolve_auth_mode(actor, dict(os.environ)) # type: ignore[arg-type]
|
|
370
|
+
return (provider, mode, credential_fingerprint(actor, dict(os.environ))) # type: ignore[arg-type]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _by_credential(config: SyncadeConfig) -> list[tuple[tuple[str, str], tuple[str, object]]]:
|
|
374
|
+
"""One representative actor per DISTINCT CREDENTIAL, in config order."""
|
|
375
|
+
seen: dict[tuple[str, str, str], tuple[str, object]] = {}
|
|
376
|
+
actors: list[object] = [
|
|
377
|
+
*config.reviewers,
|
|
378
|
+
config.producer,
|
|
379
|
+
config.synthesizer,
|
|
380
|
+
config.drafter,
|
|
381
|
+
config.auditor,
|
|
382
|
+
]
|
|
383
|
+
for actor in actors:
|
|
384
|
+
key = _credential_key(actor)
|
|
385
|
+
if key not in seen:
|
|
386
|
+
seen[key] = (str(getattr(actor, "model", "")), actor)
|
|
387
|
+
return [((k[0], f"{k[1]}:{[v for v, _h in k[2]]}"), v) for k, v in seen.items()]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _provider_actors(config: SyncadeConfig) -> dict[str, object]:
|
|
391
|
+
"""One representative actor per provider (first wins). Kept for the probe-env lookup;
|
|
392
|
+
the PROBE LIST itself is keyed by credential — see :func:`_by_credential`."""
|
|
393
|
+
seen: dict[str, object] = {}
|
|
394
|
+
for reviewer in config.reviewers:
|
|
395
|
+
seen.setdefault(reviewer.provider, reviewer)
|
|
396
|
+
for actor in (config.producer, config.synthesizer, config.drafter, config.auditor):
|
|
397
|
+
seen.setdefault(actor.provider, actor)
|
|
398
|
+
return seen
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def probe_credentials(
|
|
402
|
+
config: SyncadeConfig,
|
|
403
|
+
*,
|
|
404
|
+
timeout_seconds: float | None = None,
|
|
405
|
+
probes: dict[str, ProviderProbe] | None = None,
|
|
406
|
+
):
|
|
407
|
+
"""Yield one :class:`AuthCheckResult` per DISTINCT CREDENTIAL, in config order.
|
|
408
|
+
|
|
409
|
+
The pure probe core shared by :func:`run_auth_check` (which prints each result and maps
|
|
410
|
+
them to an exit code) and ``syncade --doctor`` (which renders them as table rows). No
|
|
411
|
+
printing, no exit code — just results. A generator so a caller can stream them as each
|
|
412
|
+
sequential probe lands. Never raises: an unknown provider, an ``api`` declaration with
|
|
413
|
+
no key, and a probe that itself throws all become a failed result, because a diagnostic
|
|
414
|
+
must report, not traceback.
|
|
415
|
+
|
|
416
|
+
Probing carries each credential's OWN actor through to the env (via
|
|
417
|
+
:func:`apply_auth_to_env`), not the provider's first actor — the wiring bug the panel
|
|
418
|
+
caught: a second ``api`` credential on a provider was probed under the first actor's env,
|
|
419
|
+
verifying a credential the real run would never use.
|
|
420
|
+
"""
|
|
421
|
+
if probes is None:
|
|
422
|
+
probes = _DEFAULT_PROBES
|
|
423
|
+
if timeout_seconds is None:
|
|
424
|
+
timeout_seconds = DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS
|
|
425
|
+
|
|
426
|
+
for (provider, _cred), (model, actor) in _by_credential(config):
|
|
427
|
+
probe = probes.get(provider)
|
|
428
|
+
if probe is None:
|
|
429
|
+
yield AuthCheckResult(
|
|
430
|
+
provider=provider,
|
|
431
|
+
model=model,
|
|
432
|
+
ok=False,
|
|
433
|
+
duration_seconds=0.0,
|
|
434
|
+
detail=f"no probe registered for provider {provider!r}. Known: {sorted(probes)!r}.",
|
|
435
|
+
)
|
|
436
|
+
continue
|
|
437
|
+
try:
|
|
438
|
+
probe_env = apply_auth_to_env(dict(os.environ), actor) # type: ignore[arg-type]
|
|
439
|
+
except ValueError as exc:
|
|
440
|
+
# `api` declared with no key: degrade gracefully, never traceback.
|
|
441
|
+
yield AuthCheckResult(
|
|
442
|
+
provider=provider, model=model, ok=False, duration_seconds=0.0, detail=str(exc)
|
|
443
|
+
)
|
|
444
|
+
continue
|
|
445
|
+
try:
|
|
446
|
+
yield probe(model, timeout_seconds, env=probe_env)
|
|
447
|
+
except Exception as exc:
|
|
448
|
+
yield AuthCheckResult(
|
|
449
|
+
provider=provider,
|
|
450
|
+
model=model,
|
|
451
|
+
ok=False,
|
|
452
|
+
duration_seconds=0.0,
|
|
453
|
+
detail=f"probe raised an unexpected exception: {exc!r}",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def run_auth_check(
|
|
458
|
+
config: SyncadeConfig,
|
|
459
|
+
*,
|
|
460
|
+
timeout_seconds: float | None = None,
|
|
461
|
+
probes: dict[str, ProviderProbe] | None = None,
|
|
462
|
+
quiet: bool = False,
|
|
463
|
+
) -> int:
|
|
464
|
+
"""Run the auth-check across all configured credentials and return
|
|
465
|
+
a CLI exit code.
|
|
466
|
+
|
|
467
|
+
Steps:
|
|
468
|
+
|
|
469
|
+
1. Collect the unique credentials from ``[[reviewers]]`` +
|
|
470
|
+
``[producer]`` via :func:`_collect_unique_providers`.
|
|
471
|
+
2. Run each credential's probe (real probe from
|
|
472
|
+
:data:`_DEFAULT_PROBES` unless ``probes`` overrides). Probes
|
|
473
|
+
run sequentially — total wall-clock is the sum of per-probe
|
|
474
|
+
durations. With two providers (anthropic + openai) and
|
|
475
|
+
healthy auth, the brief targets ~5-10s.
|
|
476
|
+
3. Report each result as it lands (so a slow probe doesn't
|
|
477
|
+
silence early results).
|
|
478
|
+
4. Return :data:`syncade.exit_codes.SUCCESS` (0) if every probe
|
|
479
|
+
succeeded; :data:`~syncade.exit_codes.WORKTREE_ERROR` (60) if
|
|
480
|
+
any failed. Same exit-60 semantics as ``--selfcheck`` — "your
|
|
481
|
+
environment isn't ready for a real run".
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
config: The operator's loaded :class:`SyncadeConfig`.
|
|
485
|
+
timeout_seconds: Optional per-probe timeout override
|
|
486
|
+
(typically from ``--timeout``). ``None`` (default) uses
|
|
487
|
+
:data:`DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS` (30s).
|
|
488
|
+
probes: Optional override of the probe registry, used by
|
|
489
|
+
tests to inject fakes. Keys are provider names matching
|
|
490
|
+
``ReviewerConfig.provider`` / ``ProducerConfig.provider``;
|
|
491
|
+
values are :data:`ProviderProbe` callables. ``None``
|
|
492
|
+
(default) routes through :data:`_DEFAULT_PROBES`.
|
|
493
|
+
quiet: Suppress informational stdout. Stderr (failure paths)
|
|
494
|
+
unaffected. Wired from ``--quiet``.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
``SUCCESS`` (0) when every probe succeeded.
|
|
498
|
+
|
|
499
|
+
``WORKTREE_ERROR`` (60) when any probe failed, when an
|
|
500
|
+
unknown provider is configured (no probe registered), or
|
|
501
|
+
when the config has zero providers (degenerate case).
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
Never. All failure modes map to a ``WORKTREE_ERROR`` return.
|
|
505
|
+
"""
|
|
506
|
+
if probes is None:
|
|
507
|
+
probes = _DEFAULT_PROBES
|
|
508
|
+
if timeout_seconds is None:
|
|
509
|
+
timeout_seconds = DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS
|
|
510
|
+
|
|
511
|
+
def _info(msg: str) -> None:
|
|
512
|
+
if not quiet:
|
|
513
|
+
print(msg)
|
|
514
|
+
|
|
515
|
+
def _err(msg: str) -> None:
|
|
516
|
+
print(msg, file=sys.stderr)
|
|
517
|
+
|
|
518
|
+
providers = _collect_unique_providers(config)
|
|
519
|
+
if not providers:
|
|
520
|
+
_err(
|
|
521
|
+
"[syncade] auth-check FAILED: config has zero providers — "
|
|
522
|
+
"ensure at least one [[reviewers]] block or [producer] is configured."
|
|
523
|
+
)
|
|
524
|
+
return WORKTREE_ERROR
|
|
525
|
+
|
|
526
|
+
_info(f"[syncade] auth-check: probing {len(providers)} credential(s)")
|
|
527
|
+
|
|
528
|
+
results: list[AuthCheckResult] = []
|
|
529
|
+
any_failed = False
|
|
530
|
+
# The per-credential probe core lives in probe_credentials() so syncade --doctor can
|
|
531
|
+
# reuse the exact same wiring (carrying each credential's own actor through to the env)
|
|
532
|
+
# rather than reimplementing it and re-introducing the "wrong actor's env" bug. This
|
|
533
|
+
# loop just prints each result as it lands and tracks failure for the exit code.
|
|
534
|
+
for result in probe_credentials(config, timeout_seconds=timeout_seconds, probes=probes):
|
|
535
|
+
results.append(result)
|
|
536
|
+
if result.ok:
|
|
537
|
+
_info(f"[auth-check] {result.provider}: {result.detail}")
|
|
538
|
+
else:
|
|
539
|
+
any_failed = True
|
|
540
|
+
_err(f"[auth-check] {result.provider}: {result.detail}")
|
|
541
|
+
|
|
542
|
+
if any_failed:
|
|
543
|
+
_err(
|
|
544
|
+
"[syncade] auth-check FAILED: one or more credentials could not "
|
|
545
|
+
"authenticate. See the per-credential detail above; re-authenticate "
|
|
546
|
+
"before invoking syncade for a real review."
|
|
547
|
+
)
|
|
548
|
+
return WORKTREE_ERROR
|
|
549
|
+
|
|
550
|
+
total_duration = sum(r.duration_seconds for r in results)
|
|
551
|
+
_info(
|
|
552
|
+
f"[syncade] auth-check OK: {len(results)} credential(s) verified in {total_duration:.1f}s"
|
|
553
|
+
)
|
|
554
|
+
return SUCCESS
|