@expo/code-review-cli 0.3.0 → 0.5.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 +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/config/schema.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
export const ReviewConfigSchema = z.object({
|
|
3
4
|
/** Default model for every agent + the coordinator. Override per-agent via
|
|
4
5
|
* frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
|
|
5
|
-
model: z.string().default(
|
|
6
|
+
model: z.string().default("openai/gpt-5.5"),
|
|
6
7
|
policy: z
|
|
7
8
|
.object({
|
|
8
9
|
includeSuggestions: z.boolean().default(false),
|
|
@@ -48,20 +49,48 @@ export const ReviewConfigSchema = z.object({
|
|
|
48
49
|
})
|
|
49
50
|
.default({ additionalIgnores: [], additionalMarkers: [] }),
|
|
50
51
|
breakGlass: z
|
|
51
|
-
.object({ marker: z.string().default(
|
|
52
|
-
.default({ marker:
|
|
53
|
-
commentTag: z.string().default(
|
|
52
|
+
.object({ marker: z.string().default("/skip-review") })
|
|
53
|
+
.default({ marker: "/skip-review" }),
|
|
54
|
+
commentTag: z.string().default("expo-ai-code-reviewer"),
|
|
55
|
+
// Two accepted shapes (see AuthConfigEntry for the canonical internal form):
|
|
56
|
+
// - legacy single credential: { mode, provider, tokenEnv }
|
|
57
|
+
// - per-provider map: { providers: { <id>: { mode, tokenEnv, upstream? } } }
|
|
58
|
+
// The map form allows a MIXED setup — e.g. the "openai" provider on a ChatGPT/Codex
|
|
59
|
+
// subscription (mode "oauth", tokenEnv = the refresh token) plus an "openai-api"
|
|
60
|
+
// alias (upstream "openai") holding a metered API key for pro-tier models the
|
|
61
|
+
// subscription doesn't offer.
|
|
62
|
+
//
|
|
63
|
+
// Union order matters: the map form must be tried FIRST — the legacy object's keys
|
|
64
|
+
// all have defaults, so a non-strict legacy parse would accept (and gut) a
|
|
65
|
+
// { providers } object by stripping the unknown key.
|
|
54
66
|
auth: z
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
.union([
|
|
68
|
+
z.object({
|
|
69
|
+
providers: z.record(z.string(), z.object({
|
|
70
|
+
// "api-key": tokenEnv holds the provider's API key.
|
|
71
|
+
// "oauth": tokenEnv holds an OAuth token, injected into an isolated
|
|
72
|
+
// OpenCode auth.json. For "openai" this is the REFRESH token from a
|
|
73
|
+
// ChatGPT/Codex sign-in (OpenCode's codex plugin mints access tokens
|
|
74
|
+
// from it). NOTE: anthropic oauth cannot work — OpenCode has no
|
|
75
|
+
// anthropic OAuth plugin and Anthropic prohibits subscription tokens
|
|
76
|
+
// in third-party tools.
|
|
77
|
+
mode: z.enum(["api-key", "oauth"]).default("api-key"),
|
|
78
|
+
tokenEnv: z.string().optional(),
|
|
79
|
+
// Set ⇒ this provider id is an ALIAS synthesized into the OpenCode
|
|
80
|
+
// config, backed by the named upstream's SDK ("openai", "anthropic",
|
|
81
|
+
// anything else = openai-compatible). Lets one upstream be reached
|
|
82
|
+
// with two credentials at once (subscription + API key).
|
|
83
|
+
upstream: z.string().optional(),
|
|
84
|
+
})),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
mode: z.enum(["api-key", "oauth"]).default("api-key"),
|
|
88
|
+
provider: z.string().default("openai"),
|
|
89
|
+
/** Env var holding the key/token. */
|
|
90
|
+
tokenEnv: z.string().optional(),
|
|
91
|
+
}),
|
|
92
|
+
])
|
|
93
|
+
.default({ mode: "api-key", provider: "openai" }),
|
|
65
94
|
review: z
|
|
66
95
|
.object({
|
|
67
96
|
// Which PRs `ecr ci` acts on — the source of truth for trigger policy (a
|
|
@@ -69,12 +98,117 @@ export const ReviewConfigSchema = z.object({
|
|
|
69
98
|
// "all" — review every PR, unless it carries the `skipLabel`.
|
|
70
99
|
// "label" — review only PRs carrying `label` (e.g. `ai-review`) or a
|
|
71
100
|
// `label:<agent>` variant. `skipLabel` still wins.
|
|
72
|
-
trigger: z.enum([
|
|
101
|
+
trigger: z.enum(["all", "label"]).default("all"),
|
|
73
102
|
// Opt-in label (and prefix for `label:<agent>`) used when trigger is "label".
|
|
74
|
-
label: z.string().default(
|
|
103
|
+
label: z.string().default("ai-review"),
|
|
75
104
|
// Opt a single PR out of review. A label (not a config flag) because labels
|
|
76
105
|
// are write-gated to maintainers — a PR author can't add one to dodge review.
|
|
77
|
-
skipLabel: z.string().default(
|
|
106
|
+
skipLabel: z.string().default("ai-review:skip"),
|
|
107
|
+
})
|
|
108
|
+
.default({ trigger: "all", label: "ai-review", skipLabel: "ai-review:skip" }),
|
|
109
|
+
});
|
|
110
|
+
/** One routing scope: ordered globs → a directory containing .expo-code-review/. */
|
|
111
|
+
export const RoutingScopeSchema = z.object({
|
|
112
|
+
/** Kebab-case id; used in comments, fingerprint namespacing, --scopes. */
|
|
113
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scope name must be kebab-case"),
|
|
114
|
+
/** Ordered globs (same dialect as noise.additionalIgnores: ** and *). */
|
|
115
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
116
|
+
/** Repo-relative dir whose .expo-code-review/ holds the scope's config ('.' = root).
|
|
117
|
+
* routing.jsonc is read from the PR-head checkout, so this field is
|
|
118
|
+
* PR-controllable input: absolute paths and `..` traversal are rejected so a
|
|
119
|
+
* scope config can never resolve outside the repo. */
|
|
120
|
+
config: z
|
|
121
|
+
.string()
|
|
122
|
+
.min(1)
|
|
123
|
+
.refine((value) => !path.isAbsolute(value) && !path.normalize(value).split(/[/\\]/).includes(".."), { message: 'scope config must be a repo-relative path without ".." segments' }),
|
|
124
|
+
});
|
|
125
|
+
export const RoutingManifestSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
/** How N scopes render on one PR. */
|
|
128
|
+
comment: z.enum(["single", "per-scope"]).default("single"),
|
|
129
|
+
/** Wall-clock budget for the per-scope review passes. Active scopes run
|
|
130
|
+
* SEQUENTIALLY in one `ecr ci` process, so the total is divided across them
|
|
131
|
+
* (not spent per scope). Absent = today's totals (zod defaults). */
|
|
132
|
+
budget: z
|
|
133
|
+
.object({
|
|
134
|
+
/** Total passes budget (minutes) split across active scopes. Sized to fit
|
|
135
|
+
* the scaffolded workflow's `timeout-minutes` (90) with margin for the
|
|
136
|
+
* coordinator (10m), verification, and git/gh overhead. The cross-file pass
|
|
137
|
+
* expands to fill whatever of this window is left (see review.ts), so this
|
|
138
|
+
* is the knob that decides how long it may trace. */
|
|
139
|
+
totalPassesMinutes: z.number().int().positive().default(55),
|
|
140
|
+
/** Per-scope floor (minutes): below this a scope review isn't worth
|
|
141
|
+
* starting, so the even split clamps up to it — even when that makes the
|
|
142
|
+
* scopes overshoot the total (ecr ci warns; doctor flags the worst case). */
|
|
143
|
+
minScopeMinutes: z.number().int().positive().default(5),
|
|
144
|
+
})
|
|
145
|
+
.default({ totalPassesMinutes: 55, minScopeMinutes: 5 }),
|
|
146
|
+
defaults: z
|
|
147
|
+
.object({
|
|
148
|
+
/** The ONLY manifest-level place auth is honored (locks the root value).
|
|
149
|
+
* Unwrap the inner `.default()` first: in zod v4 a `.default().optional()`
|
|
150
|
+
* chain still fires the default when the key is absent, which would make
|
|
151
|
+
* `defaults.auth` a phantom `{mode:'api-key',provider:'openai'}` for every
|
|
152
|
+
* manifest that omits auth and silently override the root config's real auth. */
|
|
153
|
+
auth: ReviewConfigSchema.shape.auth.unwrap().optional(),
|
|
154
|
+
/** Agent ids injected into every scope with alwaysRun, from the ROOT roster. */
|
|
155
|
+
enforceAgents: z.array(z.string()).default([]),
|
|
156
|
+
/** Root comment marker; per-scope tags derive from it. */
|
|
157
|
+
commentTag: z.string().default("expo-ai-code-reviewer"),
|
|
158
|
+
})
|
|
159
|
+
.default({ enforceAgents: [], commentTag: "expo-ai-code-reviewer" }),
|
|
160
|
+
/** Ordered; LAST matching scope wins per changed file (CODEOWNERS discipline). */
|
|
161
|
+
scopes: z.array(RoutingScopeSchema).min(1),
|
|
162
|
+
})
|
|
163
|
+
.superRefine((manifest, ctx) => {
|
|
164
|
+
// unique scope names; unique config dirs (after path.normalize).
|
|
165
|
+
const seenNames = new Set();
|
|
166
|
+
for (const scope of manifest.scopes) {
|
|
167
|
+
if (seenNames.has(scope.name)) {
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: "custom",
|
|
170
|
+
message: `duplicate scope name: ${scope.name}`,
|
|
171
|
+
path: ["scopes"],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
seenNames.add(scope.name);
|
|
175
|
+
}
|
|
176
|
+
const seenDirs = new Map();
|
|
177
|
+
for (const scope of manifest.scopes) {
|
|
178
|
+
const norm = path.normalize(scope.config).replace(/[/\\]+$/, "");
|
|
179
|
+
if (seenDirs.has(norm)) {
|
|
180
|
+
ctx.addIssue({
|
|
181
|
+
code: "custom",
|
|
182
|
+
message: `duplicate scope config dir: ${scope.config} (already used by scope "${seenDirs.get(norm)}")`,
|
|
183
|
+
path: ["scopes"],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
seenDirs.set(norm, scope.name);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* Scope config = root config MINUS the centrally locked keys. Allowlist of
|
|
191
|
+
* scope-overridable keys (Turborepo-style, graft 6): model, policy, chunk,
|
|
192
|
+
* noise (+ the prompt files living beside it: shared.md, coordinator.md,
|
|
193
|
+
* agents/). NEVER auth or breakGlass — declaring either fails parsing at the
|
|
194
|
+
* Zod level so IDE/doctor catch it before CI. commentTag is also locked: a
|
|
195
|
+
* scope's comment marker is always DERIVED (`<rootTag>:<scope>`; the default
|
|
196
|
+
* scope keeps the root tag) so `ecr ci`'s post/clear/reconcile paths and a
|
|
197
|
+
* standalone `ecr review --scope --post` always target the same marker — an
|
|
198
|
+
* honored per-scope tag would let the two halves strand each other's comments.
|
|
199
|
+
*/
|
|
200
|
+
export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
|
|
201
|
+
auth: true,
|
|
202
|
+
breakGlass: true,
|
|
203
|
+
commentTag: true,
|
|
204
|
+
}).extend({
|
|
205
|
+
auth: z
|
|
206
|
+
.never({ error: "auth is locked to the root config; remove it from this scope config" })
|
|
207
|
+
.optional(),
|
|
208
|
+
breakGlass: z.never({ error: "breakGlass is locked to the root config" }).optional(),
|
|
209
|
+
commentTag: z
|
|
210
|
+
.never({
|
|
211
|
+
error: "commentTag is locked: per-scope comment markers are derived as <rootTag>:<scope>; remove it from this scope config",
|
|
78
212
|
})
|
|
79
|
-
.
|
|
213
|
+
.optional(),
|
|
80
214
|
});
|
package/build/core/auth.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import path from
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
4
|
/** Env var each provider's SDK reads for an API key (x-api-key style). */
|
|
5
5
|
const PROVIDER_KEY_ENV = {
|
|
6
|
-
anthropic:
|
|
7
|
-
openai:
|
|
8
|
-
google:
|
|
9
|
-
openrouter:
|
|
6
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
7
|
+
openai: "OPENAI_API_KEY",
|
|
8
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
9
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
12
|
* Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names
|
|
@@ -18,39 +18,108 @@ const PROVIDER_KEY_ENV = {
|
|
|
18
18
|
* Defense-in-depth alongside loading config only from the trusted base ref.
|
|
19
19
|
*/
|
|
20
20
|
const FORBIDDEN_TOKEN_ENVS = new Set([
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
"GITHUB_TOKEN",
|
|
22
|
+
"GH_TOKEN",
|
|
23
|
+
"ACTIONS_RUNTIME_TOKEN",
|
|
24
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
25
|
+
"AWS_ACCESS_KEY_ID",
|
|
26
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
27
|
+
"AWS_SESSION_TOKEN",
|
|
28
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
29
|
+
"GCP_SERVICE_ACCOUNT_KEY",
|
|
30
|
+
"NPM_TOKEN",
|
|
31
|
+
"NODE_AUTH_TOKEN",
|
|
32
|
+
"SSH_PRIVATE_KEY",
|
|
33
33
|
]);
|
|
34
34
|
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Documented Anthropic OAuth token shape: `sk-ant-oat01-` + 95 chars = 108 total
|
|
37
|
+
* (what `claude setup-token` prints). Treated as a STRONG HINT, not a spec: it comes
|
|
38
|
+
* from observed tokens and third-party sources, not an Anthropic format guarantee, so
|
|
39
|
+
* an unrecognized shape only warns. The one exception is below — a value that becomes
|
|
40
|
+
* exactly this shape by restoring a dropped `sk-` is provably a mangled paste.
|
|
41
|
+
*/
|
|
42
|
+
const ANTHROPIC_OAUTH_PREFIX = "sk-ant-oat";
|
|
43
|
+
/** Anthropic API keys — valid credentials, but for auth.mode "api-key", not "oauth". */
|
|
44
|
+
const ANTHROPIC_API_KEY_PREFIX = "sk-ant-api";
|
|
45
|
+
/** Shorter than any credential of any provider ⇒ truncated, whatever the format. */
|
|
46
|
+
const MIN_TOKEN_LENGTH = 40;
|
|
47
|
+
/**
|
|
48
|
+
* Catch an OAuth token that CANNOT work before it costs a whole run.
|
|
49
|
+
*
|
|
50
|
+
* Motivation: OpenCode refuses a malformed credential by dropping the provider from
|
|
51
|
+
* its provider list entirely, so every configured model then reports "model not
|
|
52
|
+
* found" and nothing anywhere mentions credentials. That misdirection cost a full
|
|
53
|
+
* debugging session for a value that had simply lost its leading `sk-`.
|
|
39
54
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
55
|
+
* The bar for `ok: false` is "this cannot be a valid credential", not "this looks
|
|
56
|
+
* odd", because a false rejection blocks a working setup — a worse failure than the
|
|
57
|
+
* one being prevented. Provable: whitespace, absurdly short, an API key in oauth
|
|
58
|
+
* mode, and an OAuth token missing its `sk-`. Everything else is at most a warning,
|
|
59
|
+
* and formats of providers we don't know are never judged at all.
|
|
45
60
|
*/
|
|
46
|
-
export function
|
|
47
|
-
const {
|
|
48
|
-
|
|
61
|
+
export function checkOauthTokenShape(provider, token, tokenEnv) {
|
|
62
|
+
const ok = { ok: true, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
|
63
|
+
const fix = `Re-generate it with \`claude setup-token\` and set ${tokenEnv} to the full value.`;
|
|
64
|
+
if (token !== token.trim()) {
|
|
49
65
|
return {
|
|
50
|
-
ok:
|
|
51
|
-
detail:
|
|
66
|
+
ok: false,
|
|
67
|
+
detail: `${tokenEnv} has leading/trailing whitespace (a newline from a copy-paste or a ` +
|
|
68
|
+
`\`cat\`-ed file is the usual cause); the provider will refuse it. ${fix}`,
|
|
52
69
|
};
|
|
53
70
|
}
|
|
71
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
detail: `${tokenEnv} holds only ${token.length} characters, too short to be a real ${provider} token — it looks truncated. ${fix}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Only anthropic's formats are known well enough to say anything about.
|
|
78
|
+
if (provider !== "anthropic") {
|
|
79
|
+
return ok;
|
|
80
|
+
}
|
|
81
|
+
if (token.startsWith(ANTHROPIC_API_KEY_PREFIX)) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
detail: `${tokenEnv} holds an Anthropic API key ("${ANTHROPIC_API_KEY_PREFIX}…"), but auth.mode is ` +
|
|
85
|
+
`"oauth", which expects the OAuth token from \`claude setup-token\` ` +
|
|
86
|
+
`("${ANTHROPIC_OAUTH_PREFIX}…"). Either set auth.mode to "api-key" in ` +
|
|
87
|
+
`.expo-code-review/config.jsonc, or put an OAuth token in ${tokenEnv}.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Provable mangled paste: prepending the dropped "sk-" yields exactly the documented
|
|
91
|
+
// OAuth shape. Only this reconstruction earns a hard failure — the value is not a
|
|
92
|
+
// credential of any known kind as it stands, but is one character-for-character with
|
|
93
|
+
// its prefix restored.
|
|
94
|
+
if (!token.startsWith("sk-") && `sk-${token}`.startsWith(ANTHROPIC_OAUTH_PREFIX)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
detail: `${tokenEnv} starts with "${token.slice(0, 10)}…", which is an Anthropic OAuth token ` +
|
|
98
|
+
`missing its leading "sk-" — the prefix was dropped when the value was copied ` +
|
|
99
|
+
`(adding it back gives "${ANTHROPIC_OAUTH_PREFIX}…", the shape \`claude setup-token\` ` +
|
|
100
|
+
`prints). OpenCode refuses a malformed credential by dropping the provider entirely, ` +
|
|
101
|
+
`which then surfaces as "model not found" for every model. ${fix}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!token.startsWith(ANTHROPIC_OAUTH_PREFIX)) {
|
|
105
|
+
// Unrecognized, but we can't prove it's wrong: Anthropic can mint shapes we don't
|
|
106
|
+
// know, so advise and continue rather than refusing to run.
|
|
107
|
+
return {
|
|
108
|
+
...ok,
|
|
109
|
+
warning: `${tokenEnv} does not start with "${ANTHROPIC_OAUTH_PREFIX}…" (the shape ` +
|
|
110
|
+
`\`claude setup-token\` prints for auth.mode "oauth"). It may still be valid — but if ` +
|
|
111
|
+
`the run fails with "model not found" for every model, the credential was refused, ` +
|
|
112
|
+
`and this is the first thing to check.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return ok;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Decide whether ONE configured credential is usable, WITHOUT mutating the
|
|
119
|
+
* environment. See checkProviderAuth for the all-entries wrapper.
|
|
120
|
+
*/
|
|
121
|
+
export function checkAuthEntry(entry, env = process.env) {
|
|
122
|
+
const { mode, provider, tokenEnv, upstream } = entry;
|
|
54
123
|
if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
|
|
55
124
|
return {
|
|
56
125
|
ok: false,
|
|
@@ -59,17 +128,62 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
59
128
|
`token minted for the provider instead.`,
|
|
60
129
|
};
|
|
61
130
|
}
|
|
62
|
-
|
|
131
|
+
// A well-known provider key env may only feed THAT provider. The tokenEnv guard
|
|
132
|
+
// locks which env names are forwarded, but not where they go — without this, a
|
|
133
|
+
// PR-supplied auth entry could keep the locked name (e.g. OPENAI_API_KEY) and
|
|
134
|
+
// point its provider/upstream somewhere else, sending one provider's key to a
|
|
135
|
+
// different provider.
|
|
136
|
+
if (tokenEnv) {
|
|
137
|
+
const keyOwner = Object.entries(PROVIDER_KEY_ENV).find(([, env]) => env === tokenEnv)?.[0];
|
|
138
|
+
if (keyOwner && keyOwner !== provider && keyOwner !== upstream) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
detail: `auth for ${provider} names tokenEnv "${tokenEnv}", which is ${keyOwner}'s ` +
|
|
142
|
+
`well-known key env — refusing to send one provider's credential to another. ` +
|
|
143
|
+
`Use a credential minted for ${provider}${upstream ? ` (upstream ${upstream})` : ""}, ` +
|
|
144
|
+
`or fix the provider/upstream mapping.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (mode === "oauth") {
|
|
149
|
+
if (!tokenEnv) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
detail: `auth mode "oauth" for ${provider} requires tokenEnv to name the env var holding the OAuth token.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!env[tokenEnv]) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
detail: `auth is oauth for ${provider} but token env "${tokenEnv}" is not set.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const shape = checkOauthTokenShape(provider, env[tokenEnv], tokenEnv);
|
|
162
|
+
if (!shape.ok) {
|
|
163
|
+
return shape;
|
|
164
|
+
}
|
|
165
|
+
return { ...shape, detail: `oauth for ${provider}; token env ${tokenEnv} is set` };
|
|
166
|
+
}
|
|
167
|
+
// api-key with an upstream alias: the synthesized provider reads the key
|
|
168
|
+
// straight from {env:tokenEnv}, so both must exist — there is no ambient
|
|
169
|
+
// fallback for a provider id we invented.
|
|
170
|
+
if (upstream) {
|
|
63
171
|
if (!tokenEnv) {
|
|
64
172
|
return {
|
|
65
173
|
ok: false,
|
|
66
|
-
detail:
|
|
174
|
+
detail: `auth for ${provider} (upstream ${upstream}) requires tokenEnv — the synthesized provider reads the key from that env var.`,
|
|
67
175
|
};
|
|
68
176
|
}
|
|
69
177
|
if (!env[tokenEnv]) {
|
|
70
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
detail: `auth for ${provider} (upstream ${upstream}) names token env "${tokenEnv}" but it is not set.`,
|
|
181
|
+
};
|
|
71
182
|
}
|
|
72
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
detail: `api-key for ${provider} (upstream ${upstream}); token env ${tokenEnv} is set`,
|
|
186
|
+
};
|
|
73
187
|
}
|
|
74
188
|
// api-key: usable if the configured tokenEnv is set, or the provider's own key
|
|
75
189
|
// env is already present in the environment.
|
|
@@ -86,7 +200,7 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
86
200
|
detail: `api-key for ${provider}; no tokenEnv configured and no known key env — relying on OpenCode's own login`,
|
|
87
201
|
};
|
|
88
202
|
}
|
|
89
|
-
const names = [tokenEnv, providerKeyEnv].filter(Boolean).join(
|
|
203
|
+
const names = [tokenEnv, providerKeyEnv].filter(Boolean).join(" or ");
|
|
90
204
|
return {
|
|
91
205
|
ok: false,
|
|
92
206
|
detail: `configured api-key for ${provider} but no credential is set — set ${names}, or set ` +
|
|
@@ -94,27 +208,79 @@ export function checkProviderAuth(config, env = process.env) {
|
|
|
94
208
|
};
|
|
95
209
|
}
|
|
96
210
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
211
|
+
* Decide whether EVERY configured provider credential is usable, WITHOUT
|
|
212
|
+
* mutating the environment. Shared by `prepareAuth` (fail fast before spinning up
|
|
213
|
+
* the server and every pass) and `doctor` (report), so the two never drift.
|
|
99
214
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
215
|
+
* All entries must pass — a mixed setup with one broken credential would fail
|
|
216
|
+
* exactly the passes routed to it, which is the silent-degradation this check
|
|
217
|
+
* exists to prevent. `REVIEWER_MODEL` bypasses provider auth entirely.
|
|
218
|
+
*/
|
|
219
|
+
export function checkProviderAuth(config, env = process.env) {
|
|
220
|
+
if (env.REVIEWER_MODEL) {
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
detail: `REVIEWER_MODEL override (${env.REVIEWER_MODEL}); using OpenCode's own login for that model`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const details = [];
|
|
227
|
+
const warnings = [];
|
|
228
|
+
for (const entry of config.auth) {
|
|
229
|
+
const readiness = checkAuthEntry(entry, env);
|
|
230
|
+
if (!readiness.ok) {
|
|
231
|
+
return readiness;
|
|
232
|
+
}
|
|
233
|
+
details.push(readiness.detail);
|
|
234
|
+
if (readiness.warning) {
|
|
235
|
+
warnings.push(readiness.warning);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
detail: details.join("; "),
|
|
241
|
+
...(warnings.length > 0 ? { warning: warnings.join("; ") } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The auth.json entry for one oauth credential. Provider-shaped:
|
|
246
|
+
* - openai: the durable secret is the REFRESH token (a ChatGPT/Codex sign-in's
|
|
247
|
+
* access tokens live ~1h, shorter than a worst-case run), so store it with
|
|
248
|
+
* `expires: 0` and let OpenCode's codex plugin mint access tokens on demand.
|
|
249
|
+
* - everything else: the token IS the access credential (e.g. long-lived
|
|
250
|
+
* setup-token style bearers), far-future expiry so OpenCode never tries to
|
|
251
|
+
* refresh a credential that has no refresh half.
|
|
252
|
+
*/
|
|
253
|
+
export function oauthAuthJsonEntry(provider, token) {
|
|
254
|
+
if (provider === "openai") {
|
|
255
|
+
return { type: "oauth", access: "", refresh: token, expires: 0 };
|
|
256
|
+
}
|
|
257
|
+
return { type: "oauth", access: token, refresh: "", expires: Date.now() + YEAR_MS };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Prepare model credentials for the OpenCode server from the repo's auth entries
|
|
261
|
+
* (any mix of modes/providers). Must run before the server starts (it mutates
|
|
262
|
+
* env). Returns a cleanup handle.
|
|
263
|
+
*
|
|
264
|
+
* - `api-key` (standard provider): copy the configured token env into the
|
|
265
|
+
* provider's API-key env var (so the workflow can pass a namespaced secret and
|
|
266
|
+
* OpenCode still finds it).
|
|
267
|
+
* - `api-key` (upstream alias): nothing to do here — buildOpencodeConfig
|
|
268
|
+
* synthesizes a provider block whose options read `{env:tokenEnv}` directly.
|
|
269
|
+
* - `oauth`: write ALL oauth credentials into one isolated OpenCode `auth.json`
|
|
270
|
+
* and point OpenCode at it via XDG_DATA_HOME (see oauthAuthJsonEntry for the
|
|
271
|
+
* per-provider shapes). Isolated so it never touches the developer's real
|
|
272
|
+
* auth.json.
|
|
106
273
|
*/
|
|
107
274
|
export async function prepareAuth(config) {
|
|
108
275
|
const noop = { cleanup: async () => { } };
|
|
109
|
-
const { mode, provider, tokenEnv } = config.auth;
|
|
110
276
|
// REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
|
|
111
|
-
// common local case (e.g. the repo config targets
|
|
112
|
-
// runs against their own
|
|
277
|
+
// common local case (e.g. the repo config targets a CI credential, but a dev
|
|
278
|
+
// runs against their own OpenCode login). Don't inject the configured providers'
|
|
113
279
|
// auth; let OpenCode use whatever it's logged into for the override model.
|
|
114
280
|
if (process.env.REVIEWER_MODEL) {
|
|
115
281
|
return noop;
|
|
116
282
|
}
|
|
117
|
-
// Fail fast, before starting the server and every pass, if
|
|
283
|
+
// Fail fast, before starting the server and every pass, if ANY configured
|
|
118
284
|
// provider has no usable credential — otherwise it surfaces as N failed passes
|
|
119
285
|
// mid-run. This is the same readiness check `doctor` reports, and it also covers
|
|
120
286
|
// the forbidden-secret guard (refusing to forward a well-known unrelated secret).
|
|
@@ -122,37 +288,33 @@ export async function prepareAuth(config) {
|
|
|
122
288
|
if (!readiness.ok) {
|
|
123
289
|
throw new Error(readiness.detail);
|
|
124
290
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
if (value) {
|
|
291
|
+
const authJson = {};
|
|
292
|
+
for (const entry of config.auth) {
|
|
293
|
+
const { mode, provider, tokenEnv, upstream } = entry;
|
|
294
|
+
const value = tokenEnv ? process.env[tokenEnv] : undefined;
|
|
295
|
+
if (mode === "api-key") {
|
|
296
|
+
// Upstream aliases read {env:tokenEnv} from the synthesized provider block.
|
|
297
|
+
if (!upstream && tokenEnv && value) {
|
|
298
|
+
const target = PROVIDER_KEY_ENV[provider] ?? "ANTHROPIC_API_KEY";
|
|
299
|
+
// The explicitly-configured tokenEnv is authoritative — set it even if the
|
|
300
|
+
// provider env is already present, so config wins over ambient env.
|
|
132
301
|
process.env[target] = value;
|
|
133
302
|
}
|
|
303
|
+
continue;
|
|
134
304
|
}
|
|
305
|
+
// oauth — checkProviderAuth guarantees tokenEnv is set and present; read
|
|
306
|
+
// defensively so TypeScript narrows and this stays correct if called directly.
|
|
307
|
+
if (!value) {
|
|
308
|
+
throw new Error(`auth mode "oauth" for ${provider} requires tokenEnv to name a set OAuth token env.`);
|
|
309
|
+
}
|
|
310
|
+
authJson[provider] = oauthAuthJsonEntry(provider, value);
|
|
311
|
+
}
|
|
312
|
+
if (Object.keys(authJson).length === 0) {
|
|
135
313
|
return noop;
|
|
136
314
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!token) {
|
|
141
|
-
throw new Error('auth.mode "oauth" requires auth.tokenEnv to name a set OAuth token env.');
|
|
142
|
-
}
|
|
143
|
-
const dir = await mkdtemp(path.join(tmpdir(), 'ecr-auth-'));
|
|
144
|
-
await mkdir(path.join(dir, 'opencode'), { recursive: true });
|
|
145
|
-
const authJson = {
|
|
146
|
-
[provider]: {
|
|
147
|
-
type: 'oauth',
|
|
148
|
-
access: token,
|
|
149
|
-
refresh: '',
|
|
150
|
-
// Far-future expiry so OpenCode uses the token as-is and does not try to
|
|
151
|
-
// refresh it (setup-token tokens are long-lived and carry no refresh).
|
|
152
|
-
expires: Date.now() + YEAR_MS,
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
await writeFile(path.join(dir, 'opencode', 'auth.json'), JSON.stringify(authJson), 'utf8');
|
|
315
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ecr-auth-"));
|
|
316
|
+
await mkdir(path.join(dir, "opencode"), { recursive: true });
|
|
317
|
+
await writeFile(path.join(dir, "opencode", "auth.json"), JSON.stringify(authJson), "utf8");
|
|
156
318
|
process.env.XDG_DATA_HOME = dir;
|
|
157
319
|
return {
|
|
158
320
|
cleanup: async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { promptAndParse } from
|
|
2
|
-
import { buildCoordinatorSystem, buildCoordinatorTask } from
|
|
3
|
-
import { parseCoordinatorOutput } from
|
|
1
|
+
import { promptAndParse } from "./opencode.js";
|
|
2
|
+
import { buildCoordinatorSystem, buildCoordinatorTask } from "./prompts.js";
|
|
3
|
+
import { parseCoordinatorOutput } from "./schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* Single LLM call that dedupes, re-judges severity, and decides. Structured so it
|
|
6
6
|
* could later own a spawn tool, but for now stays a plain consolidation pass.
|
|
@@ -12,13 +12,13 @@ const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
12
12
|
export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = []) {
|
|
13
13
|
const system = buildCoordinatorSystem(config);
|
|
14
14
|
const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes);
|
|
15
|
-
const { value, cost, tokens, truncated } = await promptAndParse(handle, {
|
|
16
|
-
agent:
|
|
15
|
+
const { value, cost, tokens, truncated, model } = await promptAndParse(handle, {
|
|
16
|
+
agent: "coordinator",
|
|
17
17
|
system,
|
|
18
18
|
text,
|
|
19
|
-
title:
|
|
19
|
+
title: "review-coordinator",
|
|
20
20
|
maxWaitMs: COORDINATOR_TIMEOUT_MS,
|
|
21
21
|
finalizeOnTimeout: true,
|
|
22
22
|
}, parseCoordinatorOutput);
|
|
23
|
-
return { output: value, cost, tokens, truncated };
|
|
23
|
+
return { output: value, cost, tokens, truncated, model };
|
|
24
24
|
}
|