@expo/code-review-cli 0.6.0 → 0.8.0
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.
- package/README.md +151 -25
- package/build/cli.js +7 -0
- package/build/commands/ci.js +307 -36
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +170 -33
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +86 -11
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +99 -3
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +127 -10
- package/build/core/claude-code.js +691 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +282 -9
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +117 -15
- package/build/core/prompts.js +330 -5
- package/build/core/render.js +274 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +447 -39
- package/build/core/schema.js +219 -3
- package/build/core/scrub.js +63 -1
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +12 -0
- package/build/core/util.js +18 -0
- package/build/core/verify.js +18 -1
- package/build/reporters/github.js +544 -44
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +286 -7
- package/build/sources/local-git.js +6 -2
- package/build/sources/source.js +35 -0
- package/package.json +4 -3
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +71 -4
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +124 -1
- package/templates/workflow.yml +5 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
|
|
2
|
+
import { buildAdjudicatorSystem, buildAdjudicatorTask } from "./prompts.js";
|
|
3
|
+
import { parseAdjudication } from "./schema.js";
|
|
4
|
+
import { errorMessage } from "./util.js";
|
|
5
|
+
// Adjudication runs after coordination/verification (a serial tail step) and in
|
|
6
|
+
// parallel over records; keep the per-call bound in the shape of the main verifier's
|
|
7
|
+
// VERIFY_TIMEOUT_MS. A call that runs long finalizes on whatever it has.
|
|
8
|
+
const ADJUDICATE_TIMEOUT_MS = 3 * 60 * 1000;
|
|
9
|
+
// @ref LLP 0011#hard-floors-in-code [implements] — secrets/security can NEVER be cleared by a reply, whatever the config; a code floor `protectedCategories` can only widen, never narrow
|
|
10
|
+
/** Categories no reply can ever clear, independent of the configured protected set. */
|
|
11
|
+
export const HARD_FLOOR_CATEGORIES = ["secrets", "security"];
|
|
12
|
+
/**
|
|
13
|
+
* Whether no config — current or future — can ever let a reply clear this finding.
|
|
14
|
+
* `feedbackApplied`'s floor and `adjudicateFeedback`'s "don't spend a model call on it"
|
|
15
|
+
* skip are the same question, so they read it from one place and cannot drift.
|
|
16
|
+
* Deliberately NOT `config.protectedCategories`/`dismiss`: those are user-tunable, so a
|
|
17
|
+
* stored verdict on such a finding becomes useful the moment the config changes.
|
|
18
|
+
*/
|
|
19
|
+
function hardFloored(finding) {
|
|
20
|
+
return finding.severity === "critical" || HARD_FLOOR_CATEGORIES.includes(finding.category);
|
|
21
|
+
}
|
|
22
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — dismissal is gated by `dismiss` alone (default "never"); critical + secrets/security are floored in code, and a prompt-injected verdict can't clear them
|
|
23
|
+
/**
|
|
24
|
+
* Whether a reply actually removes this finding from the blocking set. `dismiss` is the
|
|
25
|
+
* one knob that gates clearing — it defaults to "never", so suppression is always an
|
|
26
|
+
* explicit opt-in. `mode` is a separate axis (how much machinery runs), so
|
|
27
|
+
* `dismiss: "maintainers"` works under `mode: "annotate"` with no model involved, the
|
|
28
|
+
* same trust gate as `/dismiss`. NEVER for a critical / secrets / security / protected
|
|
29
|
+
* finding — those floors live here in code, not in any prompt. A maintainer reply that
|
|
30
|
+
* CITES the finding's id clears; the PR author's cited reply clears only under
|
|
31
|
+
* `dismiss: "adjudicated"` with an "accepted" verdict. An untrusted third-party
|
|
32
|
+
* commenter (neither maintainer nor PR author) never clears — its reply is annotated
|
|
33
|
+
* only, as is any reply that merely quotes the finding.
|
|
34
|
+
*/
|
|
35
|
+
export function feedbackApplied(finding, record, config) {
|
|
36
|
+
if (config.mode === "off" || config.dismiss === "never") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
// @ref LLP 0011#a-quote-annotates-an-id-clears [implements] — a quote may annotate, only a cited id may clear, on BOTH clear paths below
|
|
40
|
+
// A quoted line is not consent: GitHub's "Quote reply" copies a comment the untrusted
|
|
41
|
+
// PR author wrote, so a maintainer can clear-by-accident (or be led to) on text they
|
|
42
|
+
// never authored. The `id:<fp>` token is printed only by our own comment and counts
|
|
43
|
+
// only outside a blockquote, so citing it is an act of the replier.
|
|
44
|
+
if (record.citedId !== true) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// A human ran `/undismiss` on this reply-cleared finding: it is pinned back to the
|
|
48
|
+
// active list, so the still-present reply must not silently re-clear it.
|
|
49
|
+
if (record.unclearedByHuman) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (hardFloored(finding)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (config.protectedCategories.includes(finding.category)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (record.maintainer) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// The adjudicated path is the PR author's alone. `record.author` is re-derived from
|
|
62
|
+
// the live comment's unspoofable login every run, so a random PR commenter — even
|
|
63
|
+
// with a model-accepted rebuttal — can never clear a finding.
|
|
64
|
+
return (config.dismiss === "adjudicated" && record.verdict === "accepted" && record.author === true);
|
|
65
|
+
}
|
|
66
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — a verdict binds to the source it judged; both merge paths (reporter + aggregate) share THIS predicate so they can never drift apart
|
|
67
|
+
/**
|
|
68
|
+
* Strip a stored decision that no longer judges the source under review. A verdict is a
|
|
69
|
+
* claim about CODE, not only about words, and `fingerprintFinding` deliberately excludes
|
|
70
|
+
* the line number, so the author can edit away the code a rebuttal relied on while the
|
|
71
|
+
* finding keeps its identity. Unknown head — no `headSha` for this run, or a record
|
|
72
|
+
* written before the field existed — counts as different, never as trusted. A record
|
|
73
|
+
* with no verdict (a maintainer reply, an unjudged annotation) has no source-dependent
|
|
74
|
+
* decision and passes through untouched. The stale `applied` drops with the verdict:
|
|
75
|
+
* carrying `true` would hide the finding for one render before the recompute.
|
|
76
|
+
*/
|
|
77
|
+
export function dropStaleVerdict(record, headSha) {
|
|
78
|
+
if (record.verdict === undefined || (headSha !== undefined && record.sourceSha === headSha)) {
|
|
79
|
+
return record;
|
|
80
|
+
}
|
|
81
|
+
const { verdict: _verdict, reason: _reason, sourceSha: _sourceSha, ...rest } = record;
|
|
82
|
+
return { ...rest, applied: false };
|
|
83
|
+
}
|
|
84
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the extra `gh pr view` runs only where the author flag can move an outcome; a skipped lookup reads exactly like a failed one (author:false, clears nothing)
|
|
85
|
+
/**
|
|
86
|
+
* Whether the PR author's login has to be resolved at all for this config. The `author`
|
|
87
|
+
* flag gates exactly one thing — the adjudicated clear path in `feedbackApplied` (and,
|
|
88
|
+
* through it, which replies are worth a model call) — so under any other `dismiss`
|
|
89
|
+
* value resolving it is a `gh` call that can never move an outcome. Absent config (the
|
|
90
|
+
* `ecr feedback` crawl) resolves nothing, which is also the fail-closed answer: an
|
|
91
|
+
* unresolved author marks no reply as the author's and clears nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function feedbackNeedsPrAuthor(config) {
|
|
94
|
+
return config?.dismiss === "adjudicated";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the command layer must wire the runReview feedback seam at all: either a
|
|
98
|
+
* model judges replies ("adjudicate"), or replies may clear findings (`dismiss` opted
|
|
99
|
+
* in) and `applied` must be computed here even though no model runs. Everything else
|
|
100
|
+
* (annotate + never) is handled by the reporter at report time, with no seam.
|
|
101
|
+
*/
|
|
102
|
+
export function feedbackNeedsRunSeam(config) {
|
|
103
|
+
return config.mode === "adjudicate" || (config.mode !== "off" && config.dismiss !== "never");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Judge each author reply against the source and record its verdict, then recompute
|
|
107
|
+
* every record's `applied` flag under the hard floors. Bounded: at most
|
|
108
|
+
* `config.maxAdjudications` model calls per run, run in parallel; records past the cap
|
|
109
|
+
* are left unjudged and counted in `skipped` (never silently dropped). Fails open — a
|
|
110
|
+
* model/parse/timeout error on one record keeps it (verdict unset) and never throws.
|
|
111
|
+
* When mode !== "adjudicate" it makes no model calls at all and only recomputes
|
|
112
|
+
* `applied` — under `dismiss: "maintainers"` a maintainer reply still clears without
|
|
113
|
+
* any model; under `dismiss: "never"` nothing does.
|
|
114
|
+
*
|
|
115
|
+
* `sourceSha` is the head commit this run reviewed: every verdict decided here is
|
|
116
|
+
* stamped with it, so a later run can tell whether the verdict still judges the same
|
|
117
|
+
* source (see mergeFeedback). A source with no resolvable head OID (a local run) stamps
|
|
118
|
+
* nothing, which makes the verdict non-carrying rather than permanent.
|
|
119
|
+
*/
|
|
120
|
+
export async function adjudicateFeedback(handle, items, config, debug = () => { }, sourceSha) {
|
|
121
|
+
let cost = 0;
|
|
122
|
+
let model;
|
|
123
|
+
const tokens = {};
|
|
124
|
+
let failed = 0;
|
|
125
|
+
// Only judge in "adjudicate" mode, and only records without a verdict already decided
|
|
126
|
+
// on a prior run (the reporter carries those forward — re-judging would re-spend the
|
|
127
|
+
// budget on the same words). A reply with no text can't be judged. Only the PR
|
|
128
|
+
// author's replies are worth judging: a maintainer clears without any verdict, and a
|
|
129
|
+
// third-party commenter can never clear (feedbackApplied), so judging either would
|
|
130
|
+
// spend the budget on a rebuttal that changes no outcome. A reply that only quotes the
|
|
131
|
+
// finding can never clear it either, and would otherwise starve a cited reply of the
|
|
132
|
+
// cap. A finding a human already restored via `/undismiss` is likewise left unjudged.
|
|
133
|
+
// A hard-floored finding is skipped for the same reason and is deliberately NOT counted
|
|
134
|
+
// in `skipped` below: that figure means "reduced coverage, raise the cap", and a verdict
|
|
135
|
+
// no config could ever act on is not coverage a higher cap would buy back.
|
|
136
|
+
// @ref LLP 0011#hard-floors-in-code [constrained-by] — the code floor also means the model never judges a critical/secrets/security rebuttal: no verdict could move that outcome
|
|
137
|
+
const toJudge = config.mode === "adjudicate"
|
|
138
|
+
? items.filter((item) => item.record.verdict === undefined &&
|
|
139
|
+
item.record.author === true &&
|
|
140
|
+
item.record.citedId === true &&
|
|
141
|
+
!item.record.unclearedByHuman &&
|
|
142
|
+
!hardFloored(item.finding) &&
|
|
143
|
+
item.replyText.trim() !== "")
|
|
144
|
+
: [];
|
|
145
|
+
// Never truncate silently: the first `maxAdjudications` are judged, the rest are
|
|
146
|
+
// reported as skipped so the caller can surface reduced coverage.
|
|
147
|
+
const within = toJudge.slice(0, config.maxAdjudications);
|
|
148
|
+
const skipped = toJudge.length - within.length;
|
|
149
|
+
if (skipped > 0) {
|
|
150
|
+
debug(`Feedback: ${skipped} repl${skipped === 1 ? "y" : "ies"} over ` +
|
|
151
|
+
`maxAdjudications=${config.maxAdjudications} — left unjudged this run.`);
|
|
152
|
+
}
|
|
153
|
+
// A successful call records the verdict against the record it judged; an error leaves
|
|
154
|
+
// it out of the map, so the record below stays untouched (fail open).
|
|
155
|
+
const judged = new Map();
|
|
156
|
+
await Promise.all(within.map(async (item, index) => {
|
|
157
|
+
try {
|
|
158
|
+
const { value, cost: callCost, tokens: callTokens, model: callModel, } = await promptAndParse(handle, {
|
|
159
|
+
// Reuse the verifier's OpenCode agent: it carries exactly the read+grep tool
|
|
160
|
+
// set the adjudicator needs (open the cited file, trace the path) and the
|
|
161
|
+
// reviewing model, and its distrust posture matches. The system prompt below
|
|
162
|
+
// is what makes this an adjudication rather than a verification.
|
|
163
|
+
agent: VERIFIER_AGENT,
|
|
164
|
+
system: buildAdjudicatorSystem(),
|
|
165
|
+
text: buildAdjudicatorTask(item.finding, item.replyText),
|
|
166
|
+
title: `adjudicate-${index}`,
|
|
167
|
+
maxWaitMs: ADJUDICATE_TIMEOUT_MS,
|
|
168
|
+
finalizeOnTimeout: true,
|
|
169
|
+
}, parseAdjudication);
|
|
170
|
+
cost += callCost;
|
|
171
|
+
addTokenUsage(tokens, callTokens);
|
|
172
|
+
model = callModel ?? model;
|
|
173
|
+
judged.set(item.record, { verdict: value.verdict, reason: value.reason });
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Fail open: an error leaves the record unjudged and the finding intact.
|
|
177
|
+
failed++;
|
|
178
|
+
debug(`Feedback: could not adjudicate a reply (${errorMessage(error)}); leaving it unjudged.`);
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
// Re-emit every record: set the verdict/reason where judged, then recompute `applied`
|
|
182
|
+
// under the hard floors (this also clears a maintainer reply, which needs no verdict).
|
|
183
|
+
const records = items.map((item) => {
|
|
184
|
+
const decision = judged.get(item.record);
|
|
185
|
+
// A fresh verdict is stamped with the source it judged (when the run knows it), so
|
|
186
|
+
// the next run re-judges the same reply once that source moves on.
|
|
187
|
+
// @ref LLP 0011#suppression-is-never-silent [implements] — the verdict is bound to the revision it was decided against
|
|
188
|
+
const withVerdict = decision
|
|
189
|
+
? { ...item.record, verdict: decision.verdict, reason: decision.reason, sourceSha }
|
|
190
|
+
: item.record;
|
|
191
|
+
return { ...withVerdict, applied: feedbackApplied(item.finding, withVerdict, config) };
|
|
192
|
+
});
|
|
193
|
+
return { records, cost, tokens, model, adjudicated: judged.size, skipped, failed };
|
|
194
|
+
}
|
package/build/core/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — deny-list, cross-provider guard, isolated OAuth staging, and the forwarding-site recheck for Claude Code
|
|
1
2
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -8,6 +9,21 @@ const PROVIDER_KEY_ENV = {
|
|
|
8
9
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
9
10
|
openrouter: "OPENROUTER_API_KEY",
|
|
10
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Provider-owned credential env vars BEYOND the x-api-key ones above: Anthropic's
|
|
14
|
+
* OAuth/subscription bearer envs. CLAUDE_CODE_OAUTH_TOKEN holds the long-lived (1-year)
|
|
15
|
+
* Claude Max/Team subscription token that `ecr setup-auth`/`claude setup-token` export,
|
|
16
|
+
* and ANTHROPIC_AUTH_TOKEN is Anthropic's documented bearer var. They belong to
|
|
17
|
+
* anthropic, so the cross-provider guard below refuses a non-anthropic entry that names
|
|
18
|
+
* one — without this, `{provider:"openai", tokenEnv:"CLAUDE_CODE_OAUTH_TOKEN"}` passes
|
|
19
|
+
* (neither a FORBIDDEN secret nor a PROVIDER_KEY_ENV value) and prepareAuth forwards the
|
|
20
|
+
* Anthropic subscription token to a foreign provider as its bearer. They are NOT in
|
|
21
|
+
* FORBIDDEN_TOKEN_ENVS because an anthropic entry may legitimately name them.
|
|
22
|
+
*/
|
|
23
|
+
const ANTHROPIC_TOKEN_ENVS = {
|
|
24
|
+
CLAUDE_CODE_OAUTH_TOKEN: "anthropic",
|
|
25
|
+
ANTHROPIC_AUTH_TOKEN: "anthropic",
|
|
26
|
+
};
|
|
11
27
|
/**
|
|
12
28
|
* Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names
|
|
13
29
|
* the env var whose value becomes the provider credential — but that config is
|
|
@@ -17,7 +33,7 @@ const PROVIDER_KEY_ENV = {
|
|
|
17
33
|
* minted for that provider, so we hard-refuse these well-known unrelated secrets.
|
|
18
34
|
* Defense-in-depth alongside loading config only from the trusted base ref.
|
|
19
35
|
*/
|
|
20
|
-
const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
36
|
+
export const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
21
37
|
"GITHUB_TOKEN",
|
|
22
38
|
"GH_TOKEN",
|
|
23
39
|
"ACTIONS_RUNTIME_TOKEN",
|
|
@@ -139,6 +155,7 @@ export function checkOauthTokenShape(provider, token, tokenEnv) {
|
|
|
139
155
|
}
|
|
140
156
|
return ok;
|
|
141
157
|
}
|
|
158
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — two deny checks: FORBIDDEN_TOKEN_ENVS refuses well-known unrelated secrets; the cross-provider ownership guard refuses a non-anthropic entry naming an ANTHROPIC_TOKEN_ENVS var
|
|
142
159
|
/**
|
|
143
160
|
* Decide whether ONE configured credential is usable, WITHOUT mutating the
|
|
144
161
|
* environment. See checkProviderAuth for the all-entries wrapper.
|
|
@@ -159,17 +176,58 @@ export function checkAuthEntry(entry, env = process.env) {
|
|
|
159
176
|
// point its provider/upstream somewhere else, sending one provider's key to a
|
|
160
177
|
// different provider.
|
|
161
178
|
if (tokenEnv) {
|
|
162
|
-
const keyOwner = Object.entries(PROVIDER_KEY_ENV).find(([, env]) => env === tokenEnv)?.[0]
|
|
179
|
+
const keyOwner = Object.entries(PROVIDER_KEY_ENV).find(([, env]) => env === tokenEnv)?.[0] ??
|
|
180
|
+
ANTHROPIC_TOKEN_ENVS[tokenEnv];
|
|
163
181
|
if (keyOwner && keyOwner !== provider && keyOwner !== upstream) {
|
|
164
182
|
return {
|
|
165
183
|
ok: false,
|
|
166
184
|
detail: `auth for ${provider} names tokenEnv "${tokenEnv}", which is ${keyOwner}'s ` +
|
|
167
|
-
`well-known
|
|
185
|
+
`well-known credential env — refusing to send one provider's credential to another. ` +
|
|
168
186
|
`Use a credential minted for ${provider}${upstream ? ` (upstream ${upstream})` : ""}, ` +
|
|
169
187
|
`or fix the provider/upstream mapping.`,
|
|
170
188
|
};
|
|
171
189
|
}
|
|
172
190
|
}
|
|
191
|
+
// anthropic is ALWAYS served by the Claude Code CLI (engine inferred from the
|
|
192
|
+
// `anthropic/…` model, not from `mode`), so `mode` is irrelevant here. The
|
|
193
|
+
// credential is the machine's `claude` login, an ambient CLAUDE_CODE_OAUTH_TOKEN,
|
|
194
|
+
// or a named tokenEnv. The CLI validates the token, so the only token-shape check
|
|
195
|
+
// here is a coarse exfil guard on a NAMED tokenEnv's value (below); the
|
|
196
|
+
// FORBIDDEN/cross-provider guards above already block the well-known secrets.
|
|
197
|
+
if (provider === "anthropic") {
|
|
198
|
+
// A named-but-unset tokenEnv is NOT fatal: startClaudeCode falls back to the
|
|
199
|
+
// machine's `claude` login (the common local case — the config names the CI
|
|
200
|
+
// secret). startClaudeCode still fails fast when neither credential exists.
|
|
201
|
+
if (tokenEnv && !env[tokenEnv]) {
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
detail: `anthropic via the Claude Code CLI; falling back to the local \`claude\` login`,
|
|
205
|
+
warning: `token env "${tokenEnv}" is not set — using the machine's \`claude\` login ` +
|
|
206
|
+
`(set it for CI/headless runs; mint with \`claude setup-token\`).`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// tokenEnv is set AND present: its value is forwarded to api.anthropic.com as the
|
|
210
|
+
// bearer. The destination is ALWAYS Anthropic, so a value that is not an Anthropic
|
|
211
|
+
// credential ("sk-ant-…" covers both the "sk-ant-oat" OAuth token and "sk-ant-api"
|
|
212
|
+
// keys) cannot authenticate there but CAN be a foreign CI secret the config named
|
|
213
|
+
// — refuse it. This meets the "cannot be valid" bar the shape heuristics use, and
|
|
214
|
+
// fires at both prepareAuth and startClaudeCode's forwarding-site recheck.
|
|
215
|
+
if (tokenEnv && env[tokenEnv] && !env[tokenEnv].startsWith("sk-ant-")) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
detail: `token env "${tokenEnv}" does not hold an Anthropic credential (expected "sk-ant-…", ` +
|
|
219
|
+
`the shape \`claude setup-token\` prints, or an Anthropic API key). anthropic is served ` +
|
|
220
|
+
`by the Claude Code CLI, which authenticates to Anthropic, so a value of another shape ` +
|
|
221
|
+
`cannot work there and would leak that secret to Anthropic — point auth.tokenEnv at a ` +
|
|
222
|
+
`token minted for Anthropic.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
detail: `anthropic via the Claude Code CLI; ` +
|
|
228
|
+
(tokenEnv ? `token env ${tokenEnv} is set` : "using the local `claude` login"),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
173
231
|
if (mode === "oauth") {
|
|
174
232
|
if (!tokenEnv) {
|
|
175
233
|
return {
|
|
@@ -233,13 +291,49 @@ export function checkAuthEntry(entry, env = process.env) {
|
|
|
233
291
|
};
|
|
234
292
|
}
|
|
235
293
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
294
|
+
* The set of providers this config actually routes a model to: the `provider/`
|
|
295
|
+
* prefix of every agent model plus the coordinator's. Returns null when no model
|
|
296
|
+
* information is available (e.g. a bare config in a unit test) — callers read that
|
|
297
|
+
* as "can't scope, consider every entry". Every real config loaded by
|
|
298
|
+
* loadReviewConfig has agents, so scoping always applies at runtime.
|
|
299
|
+
*
|
|
300
|
+
* The fixed cross-cutting/verifier roles reuse an agent's model, so their provider
|
|
301
|
+
* is already covered by the agent set — mirrors engineForModel's `provider/` split.
|
|
302
|
+
*/
|
|
303
|
+
function providersInUse(config) {
|
|
304
|
+
const models = [];
|
|
305
|
+
for (const agent of config.agents ?? []) {
|
|
306
|
+
if (agent.model) {
|
|
307
|
+
models.push(agent.model);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (config.coordinator?.model) {
|
|
311
|
+
models.push(config.coordinator.model);
|
|
312
|
+
}
|
|
313
|
+
if (models.length === 0) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const providers = new Set();
|
|
317
|
+
for (const model of models) {
|
|
318
|
+
const slash = model.indexOf("/");
|
|
319
|
+
providers.add(slash > 0 ? model.slice(0, slash) : model);
|
|
320
|
+
}
|
|
321
|
+
return providers;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Decide whether EVERY configured provider credential a model actually uses is
|
|
325
|
+
* usable, WITHOUT mutating the environment. Shared by `prepareAuth` (fail fast
|
|
326
|
+
* before spinning up the server and every pass) and `doctor` (report), so the two
|
|
327
|
+
* never drift.
|
|
239
328
|
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
329
|
+
* Scoped to providers in use: an `auth` entry for a provider no agent routes to is
|
|
330
|
+
* dead config (the shipped default is `api-key`/openai, so a config that switches
|
|
331
|
+
* every model to `anthropic/…` but leaves — or omits — the `auth` block would
|
|
332
|
+
* otherwise be spuriously blocked demanding OPENAI_API_KEY, defeating the
|
|
333
|
+
* `claude`-login fallback). Only used entries gate the run; all of them must pass —
|
|
334
|
+
* a mixed setup with one broken credential would fail exactly the passes routed to
|
|
335
|
+
* it, which is the silent-degradation this check exists to prevent. `REVIEWER_MODEL`
|
|
336
|
+
* bypasses provider auth entirely.
|
|
243
337
|
*/
|
|
244
338
|
export function checkProviderAuth(config, env = process.env) {
|
|
245
339
|
if (env.REVIEWER_MODEL) {
|
|
@@ -248,9 +342,13 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
248
342
|
detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
|
|
249
343
|
};
|
|
250
344
|
}
|
|
345
|
+
const inUse = providersInUse(config);
|
|
251
346
|
const details = [];
|
|
252
347
|
const warnings = [];
|
|
253
348
|
for (const entry of config.auth) {
|
|
349
|
+
if (inUse && !inUse.has(entry.provider)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
254
352
|
const readiness = checkAuthEntry(entry, env);
|
|
255
353
|
if (!readiness.ok) {
|
|
256
354
|
return readiness;
|
|
@@ -262,7 +360,10 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
262
360
|
}
|
|
263
361
|
return {
|
|
264
362
|
ok: true,
|
|
265
|
-
|
|
363
|
+
// No used entry (e.g. every model is anthropic and the only `auth` block is the
|
|
364
|
+
// shipped openai default): the run relies on the Claude Code CLI's own login.
|
|
365
|
+
detail: details.join("; ") ||
|
|
366
|
+
"no provider credential needed for the models in use (relying on the engine's own login)",
|
|
266
367
|
...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
|
|
267
368
|
};
|
|
268
369
|
}
|
|
@@ -299,6 +400,7 @@ export function jwtExpiryMs(token) {
|
|
|
299
400
|
* setup-token style bearers), far-future expiry so OpenCode never tries to
|
|
300
401
|
* refresh a credential that has no refresh half.
|
|
301
402
|
*/
|
|
403
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — JWT access tokens are used as-is and never refreshed; opaque tokens are stored with expires:0 for the codex refresh flow, only safe when this run is the token's sole consumer (refresh tokens are single-use)
|
|
302
404
|
export function oauthAuthJsonEntry(provider, token) {
|
|
303
405
|
if (provider === "openai" && !isJwtAccessToken(token)) {
|
|
304
406
|
return { type: "oauth", access: "", refresh: token, expires: 0 };
|
|
@@ -321,6 +423,7 @@ export function oauthAuthJsonEntry(provider, token) {
|
|
|
321
423
|
* per-provider shapes). Isolated so it never touches the developer's real
|
|
322
424
|
* auth.json.
|
|
323
425
|
*/
|
|
426
|
+
// @ref LLP 0003#credential-resolution-and-forwarding [implements] — stages all OAuth credentials into one isolated auth.json under a temp XDG_DATA_HOME; anthropic never passes through this path (its credential goes straight into startClaudeCode's child env)
|
|
324
427
|
export async function prepareAuth(config) {
|
|
325
428
|
const noop = { cleanup: async () => { } };
|
|
326
429
|
// REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
|
|
@@ -338,10 +441,24 @@ export async function prepareAuth(config) {
|
|
|
338
441
|
if (!readiness.ok) {
|
|
339
442
|
throw new Error(readiness.detail);
|
|
340
443
|
}
|
|
444
|
+
// Same scoping as checkProviderAuth: only forward credentials for providers a
|
|
445
|
+
// model actually routes to. A dead entry for an unused provider must not have its
|
|
446
|
+
// tokenEnv copied into a key env — checkProviderAuth skipped its guard, so
|
|
447
|
+
// forwarding it here would reintroduce the exact secret-forwarding it prevents.
|
|
448
|
+
const inUse = providersInUse(config);
|
|
341
449
|
const authJson = {};
|
|
342
450
|
for (const entry of config.auth) {
|
|
343
451
|
const { mode, provider, tokenEnv, upstream } = entry;
|
|
452
|
+
if (inUse && !inUse.has(provider)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
344
455
|
const value = tokenEnv ? process.env[tokenEnv] : undefined;
|
|
456
|
+
// anthropic is claude-engine-only: it never writes an OpenCode auth.json /
|
|
457
|
+
// XDG_DATA_HOME nor injects ANTHROPIC_API_KEY here — its credential is passed
|
|
458
|
+
// per-invocation via the child env built in startClaudeCode.
|
|
459
|
+
if (provider === "anthropic") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
345
462
|
if (mode === "api-key") {
|
|
346
463
|
// Upstream aliases read {env:tokenEnv} from the synthesized provider block.
|
|
347
464
|
if (!upstream && tokenEnv && value) {
|